Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.test-utils.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

406 lines
12 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: '默认项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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,
imageSequenceFrames: input.imageSequenceFrames,
imageSequenceDurationMs: input.imageSequenceDurationMs,
}));
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,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
imageSequenceFrames: input.imageSequenceFrames,
imageSequenceDurationMs: input.imageSequenceDurationMs,
}),
);
saveEditorProjectLayoutMock.mockImplementation(
async (projectId, input) => ({
projectId,
canvasId: `${projectId}:canvas:default`,
revision: input.expectedRevision + 1,
updatedAt: '2026-06-12T00:00:01.000Z',
}),
);
});
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');
});
}