Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.test-utils.ts
T
k88936 6ee33dc561
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 2m38s
Project CI / Backend tests (push) Successful in 3m23s
fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切 (#111)
- 封面使用真实画布尺寸及最终 viewport.x/y/scale。
- 320×240 封面改为居中 cover 裁切,不再从左上角取景。
- 正确绘制画布背景色。

in canva:
![shotmd-1784874081.jpg](/attachments/4ac128cc-64c8-4a78-9e75-c35902049d3e)
![shotmd-1784876202.jpg](/attachments/6197259b-887e-4fd6-95b5-b722ec670a02)

in home:
![shotmd-1784874090.jpg](/attachments/241c7cd3-1eee-4f7b-96f2-4ca0268d2bee)
![shotmd-1784876211.jpg](/attachments/aa4200b6-bef0-4122-a797-ebc082b27a17)

因为原本的布局持久化可能有一些bug,改动不相关,所以我回退到最开始的一版:
- 不额外监听画布尺寸变化,给最小尺寸兜底,(大了比小了好)
- 封面不要求强一致性,生成/上传时机仍然按照原来随布局持久化

优化了封面生成:
- 改用webp格式
- 尽量利用图片缓存

最终生成封面从700ms加速到150ms以内, 加上必须的上传和确认150ms 和 布局持久化的时间相差不多,不是特别离谱的手速都能保证两者一致

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/111
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-29 16:09:30 +08:00

387 lines
11 KiB
TypeScript

import { fireEvent } from '@testing-library/react';
import JSZip from 'jszip';
import type { ContextType } from 'react';
import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
EditorProjectLayerSnapshot,
EditorProjectResourceSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
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;
extractEditorUiDesignAssetsMock?: 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',
},
] satisfies EditorProjectResourceSnapshot[];
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',
},
] satisfies EditorProjectLayerSnapshot[];
export function withEditorProjectCanvasRevision<
T extends EditorProjectSnapshot,
>(project: T, revision = 0): T {
if (typeof project.canvas?.revision === 'number') {
return project;
}
return {
...project,
canvas: {
canvasId:
project.canvas?.canvasId ?? `${project.projectId}:canvas:default`,
projectId: project.projectId,
title: project.canvas?.title ?? '默认画布',
viewport: project.canvas?.viewport ?? project.viewport,
layers: project.canvas?.layers ?? project.layers,
revision,
layoutStorageVersion: project.canvas?.layoutStorageVersion ?? 0,
backgroundColor: project.canvas?.backgroundColor,
createdAt: project.canvas?.createdAt,
updatedAt: project.canvas?.updatedAt ?? project.updatedAt,
},
};
}
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,
extractEditorUiDesignAssetsMock,
generateEditorCharacterAnimationMock,
editEditorImageMock,
createEditorAssetMock,
createEditorProjectResourceMock,
createEditorAssetFolderMock,
updateEditorAssetMock,
updateEditorAssetFolderMock,
deleteEditorAssetFolderMock,
deleteEditorAssetMock,
loadEditorAssetLibraryMock,
loadEditorProjectMock,
loadOrCreateRecentEditorProjectMock,
renameEditorProjectMock,
saveEditorProjectLayoutMock,
}: ImageCanvasEditorViewServiceMocks) {
beforeEach(() => {
window.sessionStorage.clear();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
immediateAsync(withEditorProjectCanvasRevision({
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,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
width: input.width,
height: input.height,
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
durationSeconds: input.durationSeconds,
}));
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,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
width: input.width,
height: input.height,
sourceType: input.sourceType,
durationSeconds: input.durationSeconds,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
}),
);
saveEditorProjectLayoutMock.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
generateEditorImageMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
extractEditorUiDesignAssetsMock?.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');
});
}