diff --git a/TRACKING.md b/TRACKING.md
index 3bdc31d1..0da83738 100644
--- a/TRACKING.md
+++ b/TRACKING.md
@@ -155,3 +155,4 @@
- 2026-06-17 前端拆分第三十六阶段:继续收口 `useImageCanvasUploadWorkflow`,新增 `ImageCanvasUploadModel`,把上传目标文件夹解析、上传中素材占位卡、上传到画布的临时图层、无效 drop 坐标兜底和图片真实尺寸回填坐标计算从 hook 中抽成纯模型;upload workflow hook 保留登录恢复、文件读取、真实素材创建 API、上传进度状态和生成面板参考图写入副作用。新增模型单测覆盖文件夹兜底、占位素材、画布落点、非法坐标兜底和真实尺寸修正;`useImageCanvasUploadWorkflow` 从 546 行降至 510 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框、`上传到项目素材` 入口和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
- 2026-06-17 前端拆分第三十七阶段:继续收口 `useImageCanvasAssetLibrary`,新增 `ImageCanvasAssetLibraryModel`,把素材分组、可选择素材筛选、全选状态、素材 / 文件夹重命名、文件夹折叠、本地新建文件夹占位、持久化文件夹替换、本地删除素材、删除文件夹回默认文件夹、选择集合切换、批量删除和本地移动素材到文件夹从 hook 中抽成纯模型;asset library hook 继续保留加载账号素材库、后端 CRUD 调用、登录弹窗、DOM 框选和素材拖拽命中生命周期。新增模型单测覆盖分组 / 选择、重命名 / 折叠 / 本地文件夹、本地文件夹持久化替换、删除文件夹回默认文件夹、全选 / 批量删除和本地移动;`useImageCanvasAssetLibrary` 从 609 行降至 573 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/useImageCanvasAssetLibrary.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;切回 `打开素材` 后侧栏显示 `素材` 且 `上传到项目素材` 入口可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
- 2026-06-17 前端拆分第三十八阶段:继续收口 `useImageCanvasGenerationWorkflow`,扩展 `ImageCanvasGenerationSubmissionModel`,把图标素材批量生成的规范校验 / 描述清洗 / 请求 payload / generationInputs,以及角色动画生成的 prompt 清洗 / objectKey 优先源图 / 尺寸 / 价格 / 模型参数从 workflow hook 中抽成纯模型;workflow hook 继续保留对话状态、真实 API 调用、生成结果落图、失败恢复和角色动画面板生命周期。新增模型单测覆盖图标缺少规范、图标空描述、图标描述 trim / 参考快照,以及角色动画 trim、objectKey 源图和价格计算;`useImageCanvasGenerationWorkflow` 从 1104 行降至 1075 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;点击 `生成图标素材` 后 `Icon Generator` 占位和 `生成图标素材` 面板可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
+- 2026-06-17 前端拆分第三十九阶段:开始拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorView.test-utils` 共享默认工程 / 素材库 mock、认证上下文、pointer / dataTransfer / deferred helper 和生命周期 setup;新增 `ImageCanvasEditorAssetsIntegration.test.tsx`,把素材库默认文件夹去重、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 拖拽移动、多文件上传、未登录续传、上传占位、401 登录、素材选择模式、删除素材同步清理画布图层、素材框选、画布 drop 上传和素材库拖入画布等集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 4817 行降至 3741 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。
diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx
new file mode 100644
index 00000000..b4c8bccf
--- /dev/null
+++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx
@@ -0,0 +1,900 @@
+/* @vitest-environment jsdom */
+
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ ApiClientError,
+ AuthUiContext,
+ ImageCanvasEditorView,
+ createAuthValue,
+ createDataTransferStub,
+ createDeferred,
+ dispatchPointerEvent,
+ setupImageCanvasEditorViewTestLifecycle,
+} from './ImageCanvasEditorView.test-utils';
+
+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());
+
+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,
+ };
+});
+
+describe('ImageCanvasEditorView asset library integration', () => {
+ setupImageCanvasEditorViewTestLifecycle({
+ generateEditorImageMock,
+ generateEditorIconSpritesheetMock,
+ generateEditorCharacterAnimationMock,
+ editEditorImageMock,
+ createEditorAssetMock,
+ createEditorProjectResourceMock,
+ createEditorAssetFolderMock,
+ updateEditorAssetMock,
+ updateEditorAssetFolderMock,
+ deleteEditorAssetFolderMock,
+ deleteEditorAssetMock,
+ loadEditorAssetLibraryMock,
+ loadEditorProjectMock,
+ loadOrCreateRecentEditorProjectMock,
+ renameEditorProjectMock,
+ saveEditorProjectLayoutMock,
+ });
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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('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();
+
+ 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();
+ expect(
+ within(customFolder).getByRole('button', {
+ name: '删除素材角色草图.png',
+ }),
+ ).toBeTruthy();
+ });
+
+ await user.click(
+ within(customFolder).getByRole('button', {
+ name: '删除素材角色草图.png',
+ }),
+ );
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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('opens login before uploading assets while logged out and resumes after login', async () => {
+ const openLoginModal = vi.fn();
+ const authValue = createAuthValue({ openLoginModal });
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+ 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;
+ width: number;
+ height: number;
+ sourceType: 'uploaded';
+ }>();
+ createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
+ render();
+
+ 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: 'data:image/png;base64,cHJvZ3Jlc3M=',
+ 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(
+
+
+ ,
+ );
+
+ 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: [
+ {
+ assetId: 'asset-a',
+ folderId: 'project',
+ label: '账号素材A',
+ imageSrc: 'data:image/png;base64,YQ==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-b',
+ folderId: 'project',
+ label: '账号素材B',
+ imageSrc: 'data:image/png;base64,Yg==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ ],
+ });
+ render();
+
+ 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('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: [
+ {
+ assetId: 'asset-a',
+ folderId: 'project',
+ label: '账号素材A',
+ imageSrc: 'data:image/png;base64,YQ==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-b',
+ folderId: 'project',
+ label: '账号素材B',
+ imageSrc: 'data:image/png;base64,Yg==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ ],
+ });
+ render();
+
+ await user.click(
+ await screen.findByRole('button', { name: '添加账号素材A' }),
+ );
+ await user.click(screen.getByRole('button', { name: '添加账号素材B' }));
+ expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
+ expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy();
+
+ 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');
+ });
+
+ 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: 'data:image/png;base64,YQ==',
+ 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: [
+ {
+ assetId: 'asset-a',
+ folderId: 'project',
+ label: '账号素材A',
+ imageSrc: 'data:image/png;base64,YQ==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ ],
+ });
+ render();
+
+ 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: [
+ {
+ assetId: 'asset-a',
+ folderId: 'project',
+ label: '账号素材A',
+ imageSrc: 'data:image/png;base64,YQ==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-b',
+ folderId: 'project',
+ label: '账号素材B',
+ imageSrc: 'data:image/png;base64,Yg==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ ],
+ });
+ render();
+
+ 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();
+ 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: expect.stringMatching(/^data:image\/png;base64,/u),
+ }),
+ );
+ 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('drops files into the asset panel only once without creating canvas layers', async () => {
+ render();
+
+ 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();
+
+ 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();
+ });
+
+});
diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts
new file mode 100644
index 00000000..526e2716
--- /dev/null
+++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts
@@ -0,0 +1,342 @@
+import JSZip from 'jszip';
+import type { ContextType } from 'react';
+import { fireEvent } from '@testing-library/react';
+import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest';
+
+import { ApiClientError } from '../../services/apiClient';
+import { AuthUiContext } from '../auth/AuthUiContext';
+import { ImageCanvasEditorView } from './ImageCanvasEditorView';
+
+export { ApiClientError, AuthUiContext, ImageCanvasEditorView };
+
+export type AuthValue = NonNullable<
+ ContextType
+>;
+
+export type ImageCanvasEditorViewServiceMocks = {
+ generateEditorImageMock: Mock;
+ generateEditorIconSpritesheetMock: Mock;
+ generateEditorCharacterAnimationMock: Mock;
+ editEditorImageMock: Mock;
+ createEditorAssetMock: Mock;
+ createEditorProjectResourceMock: Mock;
+ createEditorAssetFolderMock: Mock;
+ updateEditorAssetMock: Mock;
+ updateEditorAssetFolderMock: Mock;
+ deleteEditorAssetFolderMock: Mock;
+ deleteEditorAssetMock: Mock;
+ loadEditorAssetLibraryMock: Mock;
+ loadEditorProjectMock: Mock;
+ loadOrCreateRecentEditorProjectMock: Mock;
+ renameEditorProjectMock: Mock;
+ saveEditorProjectLayoutMock: Mock;
+};
+
+const runRequiredAuthAction = (action: () => void) => action();
+
+export function createAuthValue(overrides: Partial = {}): AuthValue {
+ return {
+ user: null,
+ canAccessProtectedData: false,
+ openLoginModal: vi.fn(),
+ requireAuth: vi.fn(runRequiredAuthAction),
+ openSettingsModal: vi.fn(),
+ openAccountModal: vi.fn(),
+ setCurrentUser: vi.fn(),
+ logout: vi.fn(),
+ musicVolume: 0.5,
+ setMusicVolume: vi.fn(),
+ platformTheme: 'light',
+ setPlatformTheme: vi.fn(),
+ isHydratingSettings: false,
+ isPersistingSettings: false,
+ settingsError: null,
+ ...overrides,
+ };
+}
+
+export const defaultEditorProjectResources = [
+ {
+ resourceId: 'resource-puzzle',
+ projectId: 'editor-project-default',
+ imageSrc: '/creation-type-references/puzzle.webp',
+ width: 640,
+ height: 640,
+ sourceType: 'uploaded',
+ },
+ {
+ resourceId: 'resource-big-fish',
+ projectId: 'editor-project-default',
+ imageSrc: '/creation-type-references/big-fish.webp',
+ width: 720,
+ height: 405,
+ sourceType: 'uploaded',
+ },
+];
+
+export const defaultEditorProjectLayers = [
+ {
+ layerId: 'layer-puzzle',
+ resourceId: 'resource-puzzle',
+ title: '拼图素材',
+ x: 470,
+ y: 300,
+ width: 640,
+ height: 640,
+ originalWidth: 640,
+ originalHeight: 640,
+ zIndex: 1,
+ sourceType: 'uploaded',
+ },
+ {
+ layerId: 'layer-big-fish',
+ resourceId: 'resource-big-fish',
+ title: '大鱼素材',
+ x: 930,
+ y: 360,
+ width: 720,
+ height: 405,
+ originalWidth: 720,
+ originalHeight: 405,
+ zIndex: 2,
+ sourceType: 'uploaded',
+ },
+];
+
+export const defaultEditorAssetLibraryAssets = [
+ {
+ assetId: 'asset-puzzle',
+ folderId: 'project',
+ label: '拼图素材',
+ imageSrc: '/creation-type-references/puzzle.webp',
+ width: 640,
+ height: 640,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-match3d',
+ folderId: 'project',
+ label: '抓大鹅素材',
+ imageSrc: '/creation-type-references/match3d.webp',
+ width: 640,
+ height: 640,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-big-fish',
+ folderId: 'project',
+ label: '大鱼素材',
+ imageSrc: '/creation-type-references/big-fish.webp',
+ width: 720,
+ height: 405,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-bark-battle',
+ folderId: 'project',
+ label: '声浪素材',
+ imageSrc: '/creation-type-references/bark-battle.webp',
+ width: 640,
+ height: 900,
+ sourceType: 'uploaded',
+ },
+ {
+ assetId: 'asset-visual-novel',
+ folderId: 'project',
+ label: '视觉小说素材',
+ imageSrc: '/creation-type-references/visual-novel.webp',
+ width: 720,
+ height: 405,
+ sourceType: 'uploaded',
+ },
+];
+
+export function dispatchPointerEvent(
+ target: Element,
+ type: string,
+ init: MouseEventInit & { pointerId: number },
+) {
+ const event = new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ ...init,
+ });
+ Object.defineProperty(event, 'pointerId', { value: init.pointerId });
+ fireEvent(target, event);
+}
+
+export function immediateAsync(value: T) {
+ return {
+ then(onFulfilled: (value: T) => unknown) {
+ onFulfilled(value);
+ return {
+ catch() {},
+ };
+ },
+ };
+}
+
+export function createDataTransferStub() {
+ const store = new Map();
+ return {
+ files: [],
+ types: [] as string[],
+ dropEffect: 'none',
+ effectAllowed: 'all',
+ setData(type: string, value: string) {
+ store.set(type, value);
+ if (!this.types.includes(type)) {
+ this.types.push(type);
+ }
+ },
+ getData(type: string) {
+ return store.get(type) ?? '';
+ },
+ };
+}
+
+export function createDeferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
+export async function readZipText(zip: JSZip, path: string) {
+ const file = zip.file(path);
+ expect(file).toBeTruthy();
+ return file!.async('string');
+}
+
+export function setupImageCanvasEditorViewTestLifecycle({
+ generateEditorImageMock,
+ generateEditorIconSpritesheetMock,
+ generateEditorCharacterAnimationMock,
+ editEditorImageMock,
+ createEditorAssetMock,
+ createEditorProjectResourceMock,
+ createEditorAssetFolderMock,
+ updateEditorAssetMock,
+ updateEditorAssetFolderMock,
+ deleteEditorAssetFolderMock,
+ deleteEditorAssetMock,
+ loadEditorAssetLibraryMock,
+ loadEditorProjectMock,
+ loadOrCreateRecentEditorProjectMock,
+ renameEditorProjectMock,
+ saveEditorProjectLayoutMock,
+}: ImageCanvasEditorViewServiceMocks) {
+ beforeEach(() => {
+ loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
+ immediateAsync({
+ projectId: 'editor-project-default',
+ title: '默认项目',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: defaultEditorProjectLayers,
+ resources: defaultEditorProjectResources,
+ updatedAt: '2026-06-12T00:00:00.000Z',
+ }),
+ );
+ loadEditorAssetLibraryMock.mockImplementation(() =>
+ immediateAsync({
+ folders: [
+ {
+ folderId: 'project',
+ label: '项目素材',
+ sortOrder: 0,
+ collapsed: false,
+ systemDefault: true,
+ },
+ ],
+ assets: defaultEditorAssetLibraryAssets,
+ }),
+ );
+ createEditorAssetMock.mockImplementation(async (input) => ({
+ assetId: `persisted-${input.label}`,
+ folderId: input.folderId,
+ label: input.label,
+ imageSrc: input.imageSrc,
+ width: input.width,
+ height: input.height,
+ sourceType: input.sourceType,
+ }));
+ createEditorAssetFolderMock.mockResolvedValue({
+ folderId: 'folder-role-persisted',
+ label: '角色上传',
+ collapsed: false,
+ systemDefault: false,
+ });
+ updateEditorAssetMock.mockImplementation(async (assetId, input) => ({
+ assetId,
+ folderId: input.folderId ?? 'project',
+ label: input.label ?? '拼图素材',
+ imageSrc: '/creation-type-references/puzzle.webp',
+ width: 640,
+ height: 640,
+ sourceType: 'uploaded',
+ }));
+ renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
+ projectId,
+ title,
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: defaultEditorProjectLayers,
+ resources: defaultEditorProjectResources,
+ updatedAt: '2026-06-12T00:00:00.000Z',
+ }));
+ updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
+ folderId,
+ label: input.label ?? '角色上传',
+ collapsed: input.collapsed ?? false,
+ systemDefault: false,
+ }));
+ deleteEditorAssetFolderMock.mockResolvedValue({
+ folders: [
+ {
+ folderId: 'project',
+ label: '项目素材',
+ sortOrder: 0,
+ collapsed: false,
+ systemDefault: true,
+ },
+ ],
+ assets: [],
+ });
+ deleteEditorAssetMock.mockResolvedValue({});
+ createEditorProjectResourceMock.mockImplementation(
+ async (projectId, input) => ({
+ resourceId: `resource-${projectId}-${input.width}`,
+ projectId,
+ imageSrc: input.imageSrc,
+ width: input.width,
+ height: input.height,
+ sourceType: input.sourceType,
+ }),
+ );
+ saveEditorProjectLayoutMock.mockResolvedValue({});
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ generateEditorImageMock.mockReset();
+ generateEditorIconSpritesheetMock.mockReset();
+ generateEditorCharacterAnimationMock.mockReset();
+ editEditorImageMock.mockReset();
+ createEditorAssetMock.mockReset();
+ createEditorProjectResourceMock.mockReset();
+ createEditorAssetFolderMock.mockReset();
+ updateEditorAssetMock.mockReset();
+ updateEditorAssetFolderMock.mockReset();
+ deleteEditorAssetFolderMock.mockReset();
+ deleteEditorAssetMock.mockReset();
+ loadEditorAssetLibraryMock.mockReset();
+ loadEditorProjectMock.mockReset();
+ loadOrCreateRecentEditorProjectMock.mockReset();
+ renameEditorProjectMock.mockReset();
+ saveEditorProjectLayoutMock.mockReset();
+ window.history.replaceState(null, '', '/editor/canvas');
+ });
+}
diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx
index 617a6d99..93dc46ec 100644
--- a/src/components/image-editor/ImageCanvasEditorView.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx
@@ -10,12 +10,19 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
-import type { ContextType } from 'react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
-import { ApiClientError } from '../../services/apiClient';
-import { AuthUiContext } from '../auth/AuthUiContext';
-import { ImageCanvasEditorView } from './ImageCanvasEditorView';
+import {
+ ApiClientError,
+ AuthUiContext,
+ ImageCanvasEditorView,
+ createAuthValue,
+ createDataTransferStub,
+ createDeferred,
+ dispatchPointerEvent,
+ readZipText,
+ setupImageCanvasEditorViewTestLifecycle,
+} from './ImageCanvasEditorView.test-utils';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
@@ -34,125 +41,6 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
-type AuthValue = NonNullable>;
-
-function createAuthValue(overrides: Partial = {}): AuthValue {
- return {
- user: null,
- canAccessProtectedData: false,
- openLoginModal: vi.fn(),
- requireAuth: vi.fn((action: () => void) => action()),
- openSettingsModal: vi.fn(),
- openAccountModal: vi.fn(),
- setCurrentUser: vi.fn(),
- logout: vi.fn(),
- musicVolume: 0.5,
- setMusicVolume: vi.fn(),
- platformTheme: 'light',
- setPlatformTheme: vi.fn(),
- isHydratingSettings: false,
- isPersistingSettings: false,
- settingsError: null,
- ...overrides,
- };
-}
-
-const defaultEditorProjectResources = [
- {
- resourceId: 'resource-puzzle',
- projectId: 'editor-project-default',
- imageSrc: '/creation-type-references/puzzle.webp',
- width: 640,
- height: 640,
- sourceType: 'uploaded',
- },
- {
- resourceId: 'resource-big-fish',
- projectId: 'editor-project-default',
- imageSrc: '/creation-type-references/big-fish.webp',
- width: 720,
- height: 405,
- sourceType: 'uploaded',
- },
-];
-
-const defaultEditorProjectLayers = [
- {
- layerId: 'layer-puzzle',
- resourceId: 'resource-puzzle',
- title: '拼图素材',
- x: 470,
- y: 300,
- width: 640,
- height: 640,
- originalWidth: 640,
- originalHeight: 640,
- zIndex: 1,
- sourceType: 'uploaded',
- },
- {
- layerId: 'layer-big-fish',
- resourceId: 'resource-big-fish',
- title: '大鱼素材',
- x: 930,
- y: 360,
- width: 720,
- height: 405,
- originalWidth: 720,
- originalHeight: 405,
- zIndex: 2,
- sourceType: 'uploaded',
- },
-];
-
-const defaultEditorAssetLibraryAssets = [
- {
- assetId: 'asset-puzzle',
- folderId: 'project',
- label: '拼图素材',
- imageSrc: '/creation-type-references/puzzle.webp',
- width: 640,
- height: 640,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-match3d',
- folderId: 'project',
- label: '抓大鹅素材',
- imageSrc: '/creation-type-references/match3d.webp',
- width: 640,
- height: 640,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-big-fish',
- folderId: 'project',
- label: '大鱼素材',
- imageSrc: '/creation-type-references/big-fish.webp',
- width: 720,
- height: 405,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-bark-battle',
- folderId: 'project',
- label: '声浪素材',
- imageSrc: '/creation-type-references/bark-battle.webp',
- width: 640,
- height: 900,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-visual-novel',
- folderId: 'project',
- label: '视觉小说素材',
- imageSrc: '/creation-type-references/visual-novel.webp',
- width: 720,
- height: 405,
- sourceType: 'uploaded',
- },
-];
-
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
@@ -178,176 +66,24 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
-function dispatchPointerEvent(
- target: Element,
- type: string,
- init: MouseEventInit & { pointerId: number },
-) {
- const event = new MouseEvent(type, {
- bubbles: true,
- cancelable: true,
- ...init,
- });
- Object.defineProperty(event, 'pointerId', { value: init.pointerId });
- fireEvent(target, event);
-}
-
-function immediateAsync(value: T) {
- return {
- then(onFulfilled: (value: T) => unknown) {
- onFulfilled(value);
- return {
- catch() {},
- };
- },
- };
-}
-
-function createDataTransferStub() {
- const store = new Map();
- return {
- files: [],
- types: [] as string[],
- dropEffect: 'none',
- effectAllowed: 'all',
- setData(type: string, value: string) {
- store.set(type, value);
- if (!this.types.includes(type)) {
- this.types.push(type);
- }
- },
- getData(type: string) {
- return store.get(type) ?? '';
- },
- };
-}
-
-function createDeferred() {
- let resolve!: (value: T) => void;
- let reject!: (reason?: unknown) => void;
- const promise = new Promise((promiseResolve, promiseReject) => {
- resolve = promiseResolve;
- reject = promiseReject;
- });
- return { promise, resolve, reject };
-}
-
-async function readZipText(zip: JSZip, path: string) {
- const file = zip.file(path);
- expect(file).toBeTruthy();
- return file!.async('string');
-}
-
describe('ImageCanvasEditorView', () => {
- beforeEach(() => {
- loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
- immediateAsync({
- projectId: 'editor-project-default',
- title: '默认项目',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: defaultEditorProjectLayers,
- resources: defaultEditorProjectResources,
- updatedAt: '2026-06-12T00:00:00.000Z',
- }),
- );
- loadEditorAssetLibraryMock.mockImplementation(() =>
- immediateAsync({
- folders: [
- {
- folderId: 'project',
- label: '项目素材',
- sortOrder: 0,
- collapsed: false,
- systemDefault: true,
- },
- ],
- assets: defaultEditorAssetLibraryAssets,
- }),
- );
- createEditorAssetMock.mockImplementation(async (input) => ({
- assetId: `persisted-${input.label}`,
- folderId: input.folderId,
- label: input.label,
- imageSrc: input.imageSrc,
- width: input.width,
- height: input.height,
- sourceType: input.sourceType,
- }));
- createEditorAssetFolderMock.mockResolvedValue({
- folderId: 'folder-role-persisted',
- label: '角色上传',
- collapsed: false,
- systemDefault: false,
- });
- updateEditorAssetMock.mockImplementation(async (assetId, input) => ({
- assetId,
- folderId: input.folderId ?? 'project',
- label: input.label ?? '拼图素材',
- imageSrc: '/creation-type-references/puzzle.webp',
- width: 640,
- height: 640,
- sourceType: 'uploaded',
- }));
- renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
- projectId,
- title,
- viewport: { x: 0, y: 0, scale: 1 },
- layers: defaultEditorProjectLayers,
- resources: defaultEditorProjectResources,
- updatedAt: '2026-06-12T00:00:00.000Z',
- }));
- updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
- folderId,
- label: input.label ?? '角色上传',
- collapsed: input.collapsed ?? false,
- systemDefault: false,
- }));
- deleteEditorAssetFolderMock.mockResolvedValue({
- folders: [
- {
- folderId: 'project',
- label: '项目素材',
- sortOrder: 0,
- collapsed: false,
- systemDefault: true,
- },
- ],
- assets: [],
- });
- deleteEditorAssetMock.mockResolvedValue({});
- createEditorProjectResourceMock.mockImplementation(
- async (projectId, input) => ({
- resourceId: `resource-${projectId}-${input.width}`,
- projectId,
- imageSrc: input.imageSrc,
- width: input.width,
- height: input.height,
- sourceType: input.sourceType,
- }),
- );
- saveEditorProjectLayoutMock.mockResolvedValue({});
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- generateEditorImageMock.mockReset();
- generateEditorIconSpritesheetMock.mockReset();
- generateEditorCharacterAnimationMock.mockReset();
- editEditorImageMock.mockReset();
- createEditorAssetMock.mockReset();
- createEditorProjectResourceMock.mockReset();
- createEditorAssetFolderMock.mockReset();
- updateEditorAssetMock.mockReset();
- updateEditorAssetFolderMock.mockReset();
- deleteEditorAssetFolderMock.mockReset();
- deleteEditorAssetMock.mockReset();
- loadEditorAssetLibraryMock.mockReset();
- loadEditorProjectMock.mockReset();
- loadOrCreateRecentEditorProjectMock.mockReset();
- renameEditorProjectMock.mockReset();
- saveEditorProjectLayoutMock.mockReset();
- window.history.replaceState(null, '', '/editor/canvas');
+ setupImageCanvasEditorViewTestLifecycle({
+ generateEditorImageMock,
+ generateEditorIconSpritesheetMock,
+ generateEditorCharacterAnimationMock,
+ editEditorImageMock,
+ createEditorAssetMock,
+ createEditorProjectResourceMock,
+ createEditorAssetFolderMock,
+ updateEditorAssetMock,
+ updateEditorAssetFolderMock,
+ deleteEditorAssetFolderMock,
+ deleteEditorAssetMock,
+ loadEditorAssetLibraryMock,
+ loadEditorProjectMock,
+ loadOrCreateRecentEditorProjectMock,
+ renameEditorProjectMock,
+ saveEditorProjectLayoutMock,
});
it('loads the project from projectid query before falling back to recent project', async () => {
@@ -703,714 +439,6 @@ describe('ImageCanvasEditorView', () => {
).toBe(true);
});
- 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();
-
- 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();
-
- 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();
-
- 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('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();
-
- 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();
- expect(
- within(customFolder).getByRole('button', {
- name: '删除素材角色草图.png',
- }),
- ).toBeTruthy();
- });
-
- await user.click(
- within(customFolder).getByRole('button', {
- name: '删除素材角色草图.png',
- }),
- );
-
- 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();
-
- 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();
-
- 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();
-
- 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('opens login before uploading assets while logged out and resumes after login', async () => {
- const openLoginModal = vi.fn();
- const authValue = createAuthValue({ openLoginModal });
-
- const { rerender } = render(
-
-
- ,
- );
-
- 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(
-
-
- ,
- );
- 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;
- width: number;
- height: number;
- sourceType: 'uploaded';
- }>();
- createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
- render();
-
- 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: 'data:image/png;base64,cHJvZ3Jlc3M=',
- 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(
-
-
- ,
- );
-
- 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: [
- {
- assetId: 'asset-a',
- folderId: 'project',
- label: '账号素材A',
- imageSrc: 'data:image/png;base64,YQ==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-b',
- folderId: 'project',
- label: '账号素材B',
- imageSrc: 'data:image/png;base64,Yg==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- ],
- });
- render();
-
- 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('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: [
- {
- assetId: 'asset-a',
- folderId: 'project',
- label: '账号素材A',
- imageSrc: 'data:image/png;base64,YQ==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-b',
- folderId: 'project',
- label: '账号素材B',
- imageSrc: 'data:image/png;base64,Yg==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- ],
- });
- render();
-
- await user.click(
- await screen.findByRole('button', { name: '添加账号素材A' }),
- );
- await user.click(screen.getByRole('button', { name: '添加账号素材B' }));
- expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
- expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy();
-
- 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');
- });
-
- 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: 'data:image/png;base64,YQ==',
- 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: [
- {
- assetId: 'asset-a',
- folderId: 'project',
- label: '账号素材A',
- imageSrc: 'data:image/png;base64,YQ==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- ],
- });
- render();
-
- 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: [
- {
- assetId: 'asset-a',
- folderId: 'project',
- label: '账号素材A',
- imageSrc: 'data:image/png;base64,YQ==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- {
- assetId: 'asset-b',
- folderId: 'project',
- label: '账号素材B',
- imageSrc: 'data:image/png;base64,Yg==',
- width: 320,
- height: 240,
- sourceType: 'uploaded',
- },
- ],
- });
- render();
-
- 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('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render();
@@ -1571,110 +599,6 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
- it('drops an image file on the canvas as a new canvas layer', async () => {
- render();
- 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: expect.stringMatching(/^data:image\/png;base64,/u),
- }),
- );
- 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('drops files into the asset panel only once without creating canvas layers', async () => {
- render();
-
- 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();
-
- 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('blocks the browser context menu inside the editor workspace', () => {
render();