3c933b2202
新增画布素材导出 hook 和单测 主视图改为通过导出 hook 处理单图和整包下载 更新图片画布前端拆分文档和 TRACKING 回归记录
207 lines
6.4 KiB
TypeScript
207 lines
6.4 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import JSZip from 'jszip';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
|
|
|
function createLayer(
|
|
id: string,
|
|
overrides: Partial<CanvasLayer> = {},
|
|
): CanvasLayer {
|
|
return {
|
|
id,
|
|
resourceId: `resource-${id}`,
|
|
title: id,
|
|
src: `data:image/png;base64,${btoa(`layer-${id.replace(/[^\w-]/gu, '-')}`)}`,
|
|
x: 10,
|
|
y: 20,
|
|
width: 100,
|
|
height: 80,
|
|
originalWidth: 100,
|
|
originalHeight: 80,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function readZipText(zip: JSZip, path: string) {
|
|
const file = zip.file(path);
|
|
if (!file) {
|
|
throw new Error(`Missing zip file: ${path}`);
|
|
}
|
|
return file.async('text');
|
|
}
|
|
|
|
function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
|
|
const workflow = useImageCanvasAssetExportWorkflow({
|
|
layers,
|
|
projectId: 'project-export',
|
|
projectTitle: '导出项目',
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<span data-testid="exporting">{String(workflow.isExportingAssets)}</span>
|
|
<span data-testid="status">
|
|
{workflow.assetExportStatus
|
|
? `${workflow.assetExportStatus.tone}:${workflow.assetExportStatus.message}`
|
|
: '-'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void workflow.exportCanvasAssets();
|
|
}}
|
|
>
|
|
导出画布素材
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
|
|
>
|
|
导出单图
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasAssetExportWorkflow', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('exports canvas assets into a zip with metadata and failed image records', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
if (url === '/generated-ok.png') {
|
|
return new Response(new Blob(['generated'], { type: 'image/png' }));
|
|
}
|
|
return new Response(null, { status: 404 });
|
|
});
|
|
globalThis.fetch = fetchMock as typeof fetch;
|
|
let exportedBlob: Blob | null = null;
|
|
let downloadName = '';
|
|
Object.defineProperty(URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: vi.fn((blob: Blob) => {
|
|
exportedBlob = blob;
|
|
return 'blob:editor-export';
|
|
}),
|
|
});
|
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
|
function click(this: HTMLAnchorElement) {
|
|
downloadName = this.download;
|
|
},
|
|
);
|
|
|
|
try {
|
|
render(
|
|
<ExportWorkflowHarness
|
|
layers={[
|
|
createLayer('asset-a', {
|
|
title: '素材 A',
|
|
sourceAssetId: 'asset-a',
|
|
zIndex: 1,
|
|
hidden: true,
|
|
locked: true,
|
|
flipX: true,
|
|
}),
|
|
createLayer('asset-a-duplicate', {
|
|
title: '素材 A 副本',
|
|
sourceAssetId: 'asset-a',
|
|
zIndex: 2,
|
|
}),
|
|
createLayer('generated', {
|
|
title: '生成图',
|
|
src: '/generated-ok.png',
|
|
sourceType: 'generated',
|
|
prompt: '明亮主视觉',
|
|
zIndex: 3,
|
|
}),
|
|
createLayer('failed', {
|
|
title: '失败图',
|
|
src: '/missing.png',
|
|
zIndex: 4,
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(exportedBlob).toBeTruthy();
|
|
});
|
|
expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u);
|
|
|
|
const zip = await JSZip.loadAsync(exportedBlob!);
|
|
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
|
|
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
|
|
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
|
|
|
|
const metadata = JSON.parse(
|
|
await readZipText(zip, '导出项目-画布素材/metadata.json'),
|
|
);
|
|
expect(metadata.projectId).toBe('project-export');
|
|
expect(metadata.layers).toHaveLength(4);
|
|
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
|
|
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
|
|
expect(metadata.layers[0].canvas.hidden).toBe(true);
|
|
expect(metadata.layers[0].canvas.locked).toBe(true);
|
|
expect(metadata.layers[0].canvas.flipX).toBe(true);
|
|
expect(metadata.layers[2].sourceType).toBe('generated');
|
|
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
|
|
expect(metadata.layers[3].file).toBeNull();
|
|
expect(metadata.layers[3].exportError).toContain('404');
|
|
expect(metadata.failedImages).toHaveLength(1);
|
|
expect(
|
|
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
|
|
).toContain('失败素材数量:1');
|
|
expect(screen.getByTestId('status').textContent).toBe(
|
|
'error:部分素材未能导出',
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
|
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
|
}
|
|
});
|
|
|
|
it('reports empty exports and supports direct layer image downloads', async () => {
|
|
let downloadName = '';
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
|
function click(this: HTMLAnchorElement) {
|
|
downloadName = this.download;
|
|
},
|
|
);
|
|
render(
|
|
<ExportWorkflowHarness
|
|
layers={[createLayer('single-export', { title: '单图/导出:*?' })]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
|
|
|
|
expect(downloadName).toBe('单图 导出.png');
|
|
|
|
render(<ExportWorkflowHarness layers={[]} />);
|
|
fireEvent.click(
|
|
screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe(
|
|
'info:当前画布没有可导出的素材',
|
|
);
|
|
});
|
|
});
|
|
});
|