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>; 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 { 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(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, 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'); }); }