/* @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 { 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 readAscii(bytes: Uint8Array, start: number, end: number) { return Array.from(bytes.slice(start, end), (byte) => String.fromCharCode(byte), ).join(''); } function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) { const workflow = useImageCanvasAssetExportWorkflow({ layers, projectId: 'project-export', projectTitle: '导出项目', }); return (
{String(workflow.isExportingAssets)} {workflow.assetExportStatus ? `${workflow.assetExportStatus.tone}:${workflow.assetExportStatus.message}` : '-'}
); } 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) => { const pathname = new URL(url, 'http://localhost').pathname; if (pathname === '/generated-ok.png') { return new Response(new Blob(['generated'], { type: 'image/png' })); } if (pathname === '/generated-video.mp4') { return new Response(new Blob(['video'], { type: 'video/mp4' })); } 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( , ); 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('导出项目-画布素材/media/003-生成视频.mp4')).toBeTruthy(); expect(zip.file('导出项目-画布素材/media/004-胜利音效.wav')).toBeTruthy(); expect( zip.file( '导出项目-画布素材/sequences/005-角色动作/frames/frame-01.png', ), ).toBeTruthy(); expect( zip.file( '导出项目-画布素材/sequences/005-角色动作/frames/frame-02.png', ), ).toBeTruthy(); expect( zip.file('导出项目-画布素材/sequences/005-角色动作/manifest.txt'), ).toBeTruthy(); expect( zip.file('导出项目-画布素材/sequences/005-角色动作/skeleton.json'), ).toBeTruthy(); expect( await readZipText( zip, '导出项目-画布素材/sequences/005-角色动作/README.md', ), ).toContain('Spine JSON'); expect(zip.file('导出项目-画布素材/images/006-失败图.png')).toBeNull(); const metadata = JSON.parse( await readZipText(zip, '导出项目-画布素材/metadata.json'), ); expect(metadata.projectId).toBeUndefined(); expect(metadata.layers).toHaveLength(7); 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).toBeUndefined(); expect(metadata.layers[0].visible.type).toBe('上传图片'); expect(metadata.layers[0].visible.resolution).toBe('100 x 80 px'); expect(metadata.layers[2].visible.type).toBe('生成图片'); expect(metadata.layers[2].visible.model).toBe('-'); expect(metadata.layers[2].visible.task).toBe('987'); expect(metadata.layers[2].visible.object).toBe('asset-object-visible'); expect(metadata.layers[2].visible.generationInputs).toEqual({ fields: [{ title: '生成提示词', value: '明亮主视觉' }], references: [ { title: '参考图', label: '角色立绘', refType: 'project-resource', refId: 'resource-reference', }, ], }); expect(JSON.stringify(metadata)).not.toContain('系统固定提取提示词'); expect(metadata.layers[2].sourceType).toBeUndefined(); expect(metadata.layers[2].prompt).toBeUndefined(); expect(metadata.layers[2].actualPrompt).toBeUndefined(); expect(metadata.layers[2].provider).toBeUndefined(); expect(metadata.layers[2].sourceResourceId).toBeUndefined(); expect(metadata.layers[2].sourceAssetId).toBeUndefined(); expect(metadata.layers[3].file).toBe('media/003-生成视频.mp4'); expect(metadata.layers[3].visible.type).toBe('生成视频'); expect(metadata.layers[4].file).toBe('media/004-胜利音效.wav'); expect(metadata.layers[4].visible.type).toBe('生成音频'); expect(metadata.layers[4].visible.duration).toBe('--:--'); expect(metadata.layers[5].file).toBe( 'sequences/005-角色动作/manifest.txt', ); expect(metadata.layers[5].visible.type).toBe('角色动作序列帧'); const sequenceMetadata = JSON.parse( await readZipText( zip, '导出项目-画布素材/sequences/005-角色动作/metadata.json', ), ); expect(sequenceMetadata.visible.type).toBe('角色动作序列帧'); expect(sequenceMetadata.taskId).toBeUndefined(); expect(sequenceMetadata.previewVideoPath).toBeUndefined(); expect(sequenceMetadata.failedFrames).toEqual([]); const sequenceSpineJson = JSON.parse( await readZipText( zip, '导出项目-画布素材/sequences/005-角色动作/skeleton.json', ), ); expect(sequenceSpineJson.skeleton.images).toBe('./frames/'); expect( sequenceSpineJson.skins[0].attachments.character.animation_0001.path, ).toBe('frame-01'); expect( sequenceSpineJson.animations.animation.slots.character.attachment, ).toEqual([ { time: 0, name: 'animation_0001' }, { time: 2, name: 'animation_0002' }, { time: 4, name: 'animation_0001' }, ]); expect(metadata.layers[6].file).toBeNull(); expect(metadata.layers[6].exportError).toContain('404'); expect(metadata.failedImages).toHaveLength(1); expect(metadata.failedImages[0]).toEqual({ title: '失败图', error: expect.stringContaining('404'), }); expect(metadata.failedImages[0].key).toBeUndefined(); expect(metadata.failedImages[0].src).toBeUndefined(); 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 () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { if (url.startsWith('/api/assets/read-bytes?')) { return new Response(new Blob(['private'], { type: 'image/png' })); } if (url.startsWith('/generated/video.webm?token=1')) { return new Response(new Blob(['video'], { type: 'video/webm' })); } return new Response(null, { status: 404 }); }); globalThis.fetch = fetchMock as typeof fetch; let downloadedBlob: Blob | null = null; let downloadName = ''; let downloadHref = ''; Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((blob: Blob) => { downloadedBlob = blob; return 'blob:direct-layer-export'; }), }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn(), }); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( function click(this: HTMLAnchorElement) { downloadName = this.download; downloadHref = this.href; }, ); try { render( , ); fireEvent.click(screen.getByRole('button', { name: '导出单图' })); await waitFor(() => { expect(downloadName).toBe('单图 导出.png'); }); expect(downloadHref).toBe('blob:direct-layer-export'); expect(downloadedBlob).toBeTruthy(); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining( '/api/assets/read-bytes?objectKey=generated%2Fprivate.png', ), expect.any(Object), ); expect( fetchMock.mock.calls.some(([url]) => String(url).includes('x-oss-signature'), ), ).toBe(false); render( , ); fireEvent.click( screen.getAllByRole('button', { name: '导出单图' }).at(-1)!, ); await waitFor(() => { expect(downloadName).toBe('视频导出.webm'); }); render(); fireEvent.click( screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!, ); await waitFor(() => { expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe( 'info:当前画布没有可导出的素材', ); }); } finally { globalThis.fetch = originalFetch; delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; } }); it('downloads character animation layers as a zipped image sequence', async () => { let exportedBlob: Blob | null = null; let downloadName = ''; Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((blob: Blob) => { exportedBlob = blob; return 'blob:sequence-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( , ); fireEvent.click(screen.getByRole('button', { name: '导出单图' })); await waitFor(() => { expect(exportedBlob).toBeTruthy(); }); expect(downloadName).toBe('角色动作 跑步-SpineJSON.zip'); const zip = await JSZip.loadAsync(exportedBlob!); expect(zip.file('frames/frame-01.png')).toBeTruthy(); expect(zip.file('frames/frame-02.png')).toBeTruthy(); expect(await readZipText(zip, 'manifest.txt')).toContain( 'Spine JSON:skeleton.json', ); expect(await readZipText(zip, 'README.md')).toContain('Spine JSON'); const spineJson = JSON.parse(await readZipText(zip, 'skeleton.json')); expect(spineJson.skeleton).toMatchObject({ images: './frames/', width: 1024, height: 1024, }); expect(spineJson.skins[0].attachments.character.animation_0001).toEqual({ type: 'region', path: 'frame-01', x: 0, y: 512, width: 1024, height: 1024, }); const metadata = JSON.parse(await readZipText(zip, 'metadata.json')); expect(metadata.title).toBe('角色动作/跑步'); expect(metadata.visible.type).toBe('角色动作序列帧'); expect(metadata.visible.resolution).toBe('100 x 80 px'); expect(metadata.taskId).toBeUndefined(); expect(metadata.previewVideoPath).toBeUndefined(); expect(metadata.failedFrames).toEqual([]); } finally { delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; } }); it('downloads character animation layers as sequence zip with gif preview', async () => { Object.defineProperty(globalThis, 'createImageBitmap', { configurable: true, value: vi.fn(async () => ({ width: 1024, height: 1024 })), }); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ clearRect: vi.fn(), drawImage: vi.fn(), getImageData: vi.fn( (_x: number, _y: number, width: number, height: number) => { const data = new Uint8ClampedArray(width * height * 4); for (let index = 0; index < data.length; index += 4) { data[index] = 255; data[index + 3] = 255; } return { data }; }, ), } as unknown as CanvasRenderingContext2D); let exportedBlob: Blob | null = null; let downloadName = ''; Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((blob: Blob) => { exportedBlob = blob; return 'blob:sequence-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( , ); fireEvent.click(screen.getByRole('button', { name: '导出序列帧zip' })); await waitFor(() => { expect(exportedBlob).toBeTruthy(); }); expect(downloadName).toBe('角色动作 跑步-Sequence.zip'); const zip = await JSZip.loadAsync(exportedBlob!); expect(zip.file('frames/frame-01.png')).toBeTruthy(); expect(zip.file('frames/frame-02.png')).toBeTruthy(); const previewGif = await zip.file('preview.gif')!.async('uint8array'); expect(readAscii(previewGif, 0, 6)).toBe('GIF89a'); expect(readAscii(previewGif, 0, previewGif.length)).toContain( 'NETSCAPE2.0', ); expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧'); expect(await readZipText(zip, 'metadata.json')).toContain( '"frameCount": 2', ); expect(zip.file('skeleton.json')).toBeNull(); expect(zip.file('README.md')).toBeNull(); } finally { delete (globalThis as unknown as { createImageBitmap?: unknown }) .createImageBitmap; delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; } }); });