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();