拆分编辑器素材集成测试
新增编辑器测试共享工具 迁出素材库和上传集成用例 保留主编辑器集成测试关键链路 更新 TRACKING.md 记录第三十九阶段验证
This commit is contained in:
@@ -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 前端拆分第三十六阶段:继续收口 `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 前端拆分第三十七阶段:继续收口 `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 前端拆分第三十八阶段:继续收口 `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。
|
||||||
|
|||||||
@@ -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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('region', { name: '项目素材' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull();
|
||||||
|
expect(screen.getAllByRole('button', { name: /上传到/u })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles the shared sidebar from canvas panel buttons', () => {
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||||
|
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||||
|
const assetsButton = within(panelToolbar).getByRole('button', {
|
||||||
|
name: '打开素材',
|
||||||
|
});
|
||||||
|
const layersButton = within(panelToolbar).getByRole('button', {
|
||||||
|
name: '打开图层',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(within(sidebar).getByText('素材')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(sidebar).getByRole('button', { name: '添加拼图素材' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(assetsButton.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(layersButton);
|
||||||
|
|
||||||
|
const layerSidebar = screen.getByRole('complementary', {
|
||||||
|
name: '图片资源栏',
|
||||||
|
});
|
||||||
|
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(layersButton.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(layersButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('complementary', { name: '图片资源栏' }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(layersButton.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups assets by folder and renames sidebar materials', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||||
|
expect(
|
||||||
|
within(sidebar).getByRole('region', { name: '项目素材' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(sidebar).queryByRole('region', { name: '参考素材' }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: '重命名素材拼图素材' }),
|
||||||
|
);
|
||||||
|
const renameInput = screen.getByLabelText('重命名素材拼图素材');
|
||||||
|
await user.clear(renameInput);
|
||||||
|
await user.type(renameInput, '主视觉素材');
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: '保存素材拼图素材名称' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
||||||
|
await user.click(screen.getByRole('button', { name: '添加主视觉素材' }));
|
||||||
|
|
||||||
|
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
value: createObjectUrlSpy,
|
||||||
|
});
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '折叠项目素材' }));
|
||||||
|
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
|
||||||
|
await user.click(screen.getByRole('button', { name: '展开项目素材' }));
|
||||||
|
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
|
||||||
|
const folderNameInput = screen.getByLabelText('素材文件夹名称');
|
||||||
|
await user.type(folderNameInput, '角色上传');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
|
||||||
|
|
||||||
|
const uploadInput = screen.getByLabelText('上传图片文件');
|
||||||
|
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
|
||||||
|
await userEvent.upload(
|
||||||
|
uploadInput,
|
||||||
|
new File(['image'], '角色草图.png', { type: 'image/png' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const customFolder = screen.getByRole('region', { name: '角色上传' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
within(customFolder).getByRole('button', { name: '添加角色草图.png' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await screen.findByRole('region', { name: '角色' });
|
||||||
|
await user.click(screen.getByRole('button', { name: '重命名文件夹角色' }));
|
||||||
|
const folderRenameInput = screen.getByLabelText('重命名文件夹角色');
|
||||||
|
await user.clear(folderRenameInput);
|
||||||
|
await user.type(folderRenameInput, '角色参考');
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: '保存文件夹角色名称' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', {
|
||||||
|
label: '角色参考',
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: '删除文件夹角色参考' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an asset to another folder when dragging inside the asset library', async () => {
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderId: 'folder-role',
|
||||||
|
label: '角色',
|
||||||
|
sortOrder: 100,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
assetId: 'asset-puzzle',
|
||||||
|
folderId: 'project',
|
||||||
|
label: '拼图素材',
|
||||||
|
imageSrc: '/creation-type-references/puzzle.webp',
|
||||||
|
width: 640,
|
||||||
|
height: 640,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
const sourceAsset = await screen.findByRole('button', {
|
||||||
|
name: '添加拼图素材',
|
||||||
|
});
|
||||||
|
const sourceAssetRow = sourceAsset.closest(
|
||||||
|
'.image-canvas-editor__asset-row',
|
||||||
|
);
|
||||||
|
const projectFolder = screen.getByRole('region', { name: '项目素材' });
|
||||||
|
const roleFolder = screen.getByRole('region', { name: '角色' });
|
||||||
|
const dataTransfer = createDataTransferStub();
|
||||||
|
|
||||||
|
if (!sourceAssetRow) {
|
||||||
|
throw new Error('asset row should exist');
|
||||||
|
}
|
||||||
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
||||||
|
fireEvent.dragOver(roleFolder, { dataTransfer });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('添加到素材')).toBeNull();
|
||||||
|
expect(roleFolder.className).toContain(
|
||||||
|
'image-canvas-editor__asset-folder--move-target',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
fireEvent.drop(roleFolder, { dataTransfer });
|
||||||
|
|
||||||
|
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', {
|
||||||
|
folderId: 'folder-role',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
within(projectFolder).queryByRole('button', { name: '添加拼图素材' }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
within(roleFolder).getByRole('button', { name: '添加拼图素材' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||||
|
new File(['image-a'], '第一张.png', { type: 'image/png' }),
|
||||||
|
new File(['image-b'], '第二张.png', { type: 'image/png' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '添加第一张.png' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '添加第二张.png' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull();
|
||||||
|
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens login before uploading assets while logged out and resumes after login', async () => {
|
||||||
|
const openLoginModal = vi.fn();
|
||||||
|
const authValue = createAuthValue({ openLoginModal });
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AuthUiContext.Provider value={authValue}>
|
||||||
|
<ImageCanvasEditorView />
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||||
|
new File(['image'], '登录后上传.png', { type: 'image/png' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(openLoginModal).toHaveBeenCalled();
|
||||||
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const resumeUpload =
|
||||||
|
openLoginModal.mock.calls[openLoginModal.mock.calls.length - 1]?.[0];
|
||||||
|
expect(typeof resumeUpload).toBe('function');
|
||||||
|
rerender(
|
||||||
|
<AuthUiContext.Provider
|
||||||
|
value={createAuthValue({
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
publicUserCode: 'U001',
|
||||||
|
displayName: '测试用户',
|
||||||
|
avatarUrl: null,
|
||||||
|
phoneNumberMasked: '138****0000',
|
||||||
|
loginMethod: 'password',
|
||||||
|
bindingStatus: 'active',
|
||||||
|
wechatBound: false,
|
||||||
|
},
|
||||||
|
canAccessProtectedData: true,
|
||||||
|
openLoginModal,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ImageCanvasEditorView />
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
(resumeUpload as () => void)();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an uploading placeholder card before restoring the normal asset card', async () => {
|
||||||
|
const deferredAsset = createDeferred<{
|
||||||
|
assetId: string;
|
||||||
|
folderId: string;
|
||||||
|
label: string;
|
||||||
|
imageSrc: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sourceType: 'uploaded';
|
||||||
|
}>();
|
||||||
|
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||||
|
new File(['image'], '素材上传进度.png', { type: 'image/png' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByLabelText('素材素材上传进度.png上传进度'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '上传中素材上传进度.png' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
deferredAsset.resolve({
|
||||||
|
assetId: 'asset-upload-progress',
|
||||||
|
folderId: 'project',
|
||||||
|
label: '素材上传进度.png',
|
||||||
|
imageSrc: '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(
|
||||||
|
<AuthUiContext.Provider
|
||||||
|
value={createAuthValue({
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
publicUserCode: 'U001',
|
||||||
|
displayName: '测试用户',
|
||||||
|
avatarUrl: null,
|
||||||
|
phoneNumberMasked: '138****0000',
|
||||||
|
loginMethod: 'password',
|
||||||
|
bindingStatus: 'active',
|
||||||
|
wechatBound: false,
|
||||||
|
},
|
||||||
|
canAccessProtectedData: true,
|
||||||
|
openLoginModal,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ImageCanvasEditorView />
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||||
|
new File(['image'], '过期登录.png', { type: 'image/png' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(screen.getByText('请先登录')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports asset selection mode and batch delete with shared toolbar', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: '添加账号素材A' });
|
||||||
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
|
||||||
|
|
||||||
|
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
||||||
|
expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy();
|
||||||
|
await user.click(
|
||||||
|
within(batchToolbar).getByRole('button', { name: '删除' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: '选择素材账号素材A' }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', { name: '添加账号素材A' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
expect.objectContaining({
|
||||||
|
layers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: '账号素材A',
|
||||||
|
resourceId: 'resource-added-asset-a',
|
||||||
|
sourceAssetId: 'asset-a',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects multiple assets with a marquee in asset selection mode', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
const firstAssetButton = await screen.findByRole('button', {
|
||||||
|
name: '添加账号素材A',
|
||||||
|
});
|
||||||
|
const secondAssetButton = screen.getByRole('button', {
|
||||||
|
name: '添加账号素材B',
|
||||||
|
});
|
||||||
|
const assetList = firstAssetButton.closest(
|
||||||
|
'.image-canvas-editor__asset-list',
|
||||||
|
) as HTMLElement;
|
||||||
|
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 320,
|
||||||
|
bottom: 600,
|
||||||
|
width: 320,
|
||||||
|
height: 600,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
vi.spyOn(
|
||||||
|
firstAssetButton.closest('[data-asset-id]') as HTMLElement,
|
||||||
|
'getBoundingClientRect',
|
||||||
|
).mockReturnValue({
|
||||||
|
x: 16,
|
||||||
|
y: 120,
|
||||||
|
left: 16,
|
||||||
|
top: 120,
|
||||||
|
right: 280,
|
||||||
|
bottom: 200,
|
||||||
|
width: 264,
|
||||||
|
height: 80,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
vi.spyOn(
|
||||||
|
secondAssetButton.closest('[data-asset-id]') as HTMLElement,
|
||||||
|
'getBoundingClientRect',
|
||||||
|
).mockReturnValue({
|
||||||
|
x: 16,
|
||||||
|
y: 240,
|
||||||
|
left: 16,
|
||||||
|
top: 240,
|
||||||
|
right: 280,
|
||||||
|
bottom: 320,
|
||||||
|
width: 264,
|
||||||
|
height: 80,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
|
||||||
|
dispatchPointerEvent(assetList, 'pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 88,
|
||||||
|
clientX: 8,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
dispatchPointerEvent(assetList, 'pointermove', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 88,
|
||||||
|
clientX: 300,
|
||||||
|
clientY: 330,
|
||||||
|
});
|
||||||
|
dispatchPointerEvent(assetList, 'pointerup', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 88,
|
||||||
|
clientX: 300,
|
||||||
|
clientY: 330,
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
||||||
|
expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('drops an image file on the canvas as a new canvas layer', async () => {
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewport = screen.getByLabelText('画布工作区');
|
||||||
|
fireEvent.drop(viewport, {
|
||||||
|
clientX: 430,
|
||||||
|
clientY: 260,
|
||||||
|
dataTransfer: {
|
||||||
|
files: [new File(['image'], '测试上传.png', { type: 'image/png' })],
|
||||||
|
types: ['Files'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
label: '测试上传.png',
|
||||||
|
imageSrc: 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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), {
|
||||||
|
dataTransfer: {
|
||||||
|
files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })],
|
||||||
|
types: ['Files'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '添加素材拖拽.png' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds an asset library image to the canvas by dragging it onto the viewport', async () => {
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
const sourceAsset = await screen.findByRole('button', {
|
||||||
|
name: '添加抓大鹅素材',
|
||||||
|
});
|
||||||
|
const sourceAssetRow = sourceAsset.closest(
|
||||||
|
'.image-canvas-editor__asset-row',
|
||||||
|
);
|
||||||
|
const viewport = screen.getByLabelText('画布工作区');
|
||||||
|
const dataTransfer = createDataTransferStub();
|
||||||
|
|
||||||
|
if (!sourceAssetRow) {
|
||||||
|
throw new Error('asset row should exist');
|
||||||
|
}
|
||||||
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
||||||
|
fireEvent.dragOver(viewport, {
|
||||||
|
clientX: 520,
|
||||||
|
clientY: 300,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('添加到画布')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.drop(viewport, {
|
||||||
|
clientX: 520,
|
||||||
|
clientY: 300,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('添加到画布')).toBeNull();
|
||||||
|
});
|
||||||
|
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy();
|
||||||
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSrc: '/creation-type-references/match3d.webp',
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
342
src/components/image-editor/ImageCanvasEditorView.test-utils.ts
Normal file
342
src/components/image-editor/ImageCanvasEditorView.test-utils.ts
Normal file
@@ -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<typeof AuthUiContext>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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> = {}): 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<T>(value: T) {
|
||||||
|
return {
|
||||||
|
then(onFulfilled: (value: T) => unknown) {
|
||||||
|
onFulfilled(value);
|
||||||
|
return {
|
||||||
|
catch() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDataTransferStub() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
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<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((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');
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user