Files
Genarrative/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
T
zhaojingyan eb608ac6f2 完善图片画布撤销保护与操作提示 (#89)
为画布历史记录补充操作类型和内容消失保护

增加撤销成功、恢复成功及阻止提示并在三秒后隐藏

补充拖动、上传、生成和替换等操作的历史语义

保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈

补充撤销与恢复规则的定向测试和说明文档

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89
Co-authored-by: oj-afraid-student <1373241747@qq.com>
Co-committed-by: oj-afraid-student <1373241747@qq.com>
2026-07-20 15:19:48 +08:00

903 lines
29 KiB
TypeScript

/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasLayer,
CanvasTool,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
const createEditorAssetMock = vi.hoisted(() => vi.fn());
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const uploadEditorSeedanceReferenceFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
let createdMediaElements: HTMLMediaElement[] = [];
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
createEditorAsset: createEditorAssetMock,
createEditorAssetFolder: createEditorAssetFolderMock,
createEditorProjectResource: createEditorProjectResourceMock,
};
});
vi.mock('../../services/image-editor/editorReferenceUploadClient', () => ({
uploadEditorSeedanceReferenceFile: uploadEditorSeedanceReferenceFileMock,
}));
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
function createDefaultFolder(): EditorAssetFolder {
return {
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
};
}
function createTestFile(name = '上传素材.png') {
return new File(['image'], name, { type: 'image/png' });
}
function createSizedPngFile(name: string, width: number, height: number) {
const bytes = new Uint8Array([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0,
0,
0,
13,
0x49,
0x48,
0x44,
0x52,
(width >> 24) & 0xff,
(width >> 16) & 0xff,
(width >> 8) & 0xff,
width & 0xff,
(height >> 24) & 0xff,
(height >> 16) & 0xff,
(height >> 8) & 0xff,
height & 0xff,
8,
6,
0,
0,
0,
0,
0,
0,
0,
]);
return new File([bytes], name, { type: 'image/png' });
}
function setVideoElementSize(
element: HTMLMediaElement | undefined,
width: number,
height: number,
) {
Object.defineProperty(element, 'videoWidth', {
configurable: true,
value: width,
});
Object.defineProperty(element, 'videoHeight', {
configurable: true,
value: height,
});
}
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 };
}
function UploadWorkflowHarness({
canAccessProtectedData = true,
openEditorLoginModal = vi.fn(),
activeTool = 'select',
projectId = 'project-1',
}: {
canAccessProtectedData?: boolean;
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
activeTool?: CanvasTool;
projectId?: string | null;
}) {
const [assetFolders, setAssetFolders] = useState<EditorAssetFolder[]>([
createDefaultFolder(),
]);
const [assets, setAssets] = useState<EditorAsset[]>([]);
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [uiAssetExtractionReferences, setUiAssetExtractionReferences] =
useState<NonNullable<GenerateDialogState['generationReferences']>>([]);
const [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>(null);
const [, setQuickEditPanel] = useState<QuickEditPanelState | null>(null);
const [activeSidebarPanel] = useState<SidebarPanel | null>('assets');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const uploadIndexRef = useRef(0);
const workflow = useImageCanvasUploadWorkflow({
canAccessProtectedData,
openEditorLoginModal,
projectId,
assetFolders,
activeUploadFolderId: 'project',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
activeTool,
allocateUploadIndex: () => {
uploadIndexRef.current += 1;
return uploadIndexRef.current;
},
setAssetFolders,
setAssets,
setLayers,
setGenerateDialog,
setQuickEditPanel,
appendUiAssetExtractionReferences: (references) =>
setUiAssetExtractionReferences((currentReferences) => [
...currentReferences,
...references,
]),
appendCanvasLayersWithResources: (nextLayers) => {
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
captureCanvasHistory: vi.fn(),
selectSingleLayer: setSelectedLayerId,
});
return (
<div>
<input
ref={workflow.uploadInputRef}
type="file"
aria-label="上传图片文件"
multiple
onChange={workflow.handleUploadInputChange}
/>
<span data-testid="assets">
{assets
.map(
(asset) =>
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}:${asset.width}x${asset.height}`,
)
.join('|')}
</span>
<span data-testid="folders">
{assetFolders
.map(
(folder) =>
`${folder.id}:${folder.collapsed ? 'collapsed' : 'open'}`,
)
.join('|')}
</span>
<span data-testid="layers">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}:${layer.width}x${layer.height}:${layer.objectKey ?? '-'}`,
)
.join('|')}
</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
<span data-testid="dialog">
{generateDialog
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}:${reference.sourceAssetId ?? '-'}`).join(',') || '-'}`
: '-'}
</span>
<span data-testid="ui-asset-extraction-references">
{uiAssetExtractionReferences
.map(
(reference) =>
`${reference.label}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}`,
)
.join('|') || '-'}
</span>
<button
type="button"
onClick={() => workflow.addUploadedFiles([createTestFile()])}
>
上传素材
</button>
<button type="button" onClick={() => workflow.requestUpload('asset')}>
请求素材上传
</button>
<button
type="button"
onClick={() =>
workflow.addUploadedFiles([createTestFile('画布素材.png')], {
addToCanvas: true,
canvasPoint: { x: 110, y: 120 },
})
}
>
上传到画布
</button>
<button
type="button"
onClick={() =>
workflow.addUploadedFiles(
[createSizedPngFile('原始尺寸素材.png', 1536, 1024)],
{
addToCanvas: true,
canvasPoint: { x: 110, y: 120 },
},
)
}
>
上传原始尺寸到画布
</button>
<button
type="button"
onClick={() =>
workflow.addUploadedFiles(
[new File(['video'], '画布视频.mp4', { type: 'video/mp4' })],
{
addToCanvas: true,
canvasPoint: { x: 110, y: 120 },
},
)
}
>
上传视频到画布
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'generate',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
})
}
>
准备图片生成
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
})
}
>
准备角色生成
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'spec',
specType: 'ui',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
})
}
>
准备规范生成
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'video',
prompt: '',
status: 'failed',
generationReferences: [],
})
}
>
准备视频生成
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('generation-reference')}
>
选择生成参考图
</button>
<button
type="button"
onClick={() =>
workflow.setUploadTarget('ui-asset-extraction-reference')
}
>
选择UI素材提取参考图
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('spec-reference')}
>
选择规范参考图
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('character-spec')}
>
选择角色规范
</button>
<button
type="button"
onClick={() => workflow.requestUpload('character-spec')}
>
请求角色规范上传
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('video-reference-video')}
>
选择参考视频
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('video-reference-audio')}
>
选择参考音频
</button>
</div>
);
}
describe('useImageCanvasUploadWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
createEditorAssetMock.mockImplementation(async (input) => ({
assetId: `persisted-${input.label}`,
folderId: input.folderId,
label: input.label,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
objectKey: 'object-key-uploaded',
assetObjectId: 'asset-object-uploaded',
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-uploaded',
label: '上传素材',
collapsed: false,
systemDefault: false,
});
createEditorProjectResourceMock.mockImplementation(
async (projectId, input) => ({
resourceId: `resource-${input.objectKey ?? input.imageSrc.slice(0, 8)}`,
projectId,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
durationSeconds: input.durationSeconds,
}),
);
uploadEditorSeedanceReferenceFileMock.mockImplementation(
async (file: File, mediaType: 'video' | 'audio') => ({
src: `https://signed.example.com/${mediaType}/${file.name}`,
objectKey: `generated-character-drafts/editor/seedance-references/${mediaType}/${file.name}`,
assetObjectId: `assetobj-${mediaType}`,
}),
);
uploadEditorMediaAssetFileMock.mockImplementation(
async (file: File, mediaType: 'image' | 'video' | 'audio') => ({
src: `https://signed.example.com/editor/${mediaType}/${file.name}`,
legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
assetObjectId: `assetobj-editor-${mediaType}`,
}),
);
Object.defineProperty(HTMLMediaElement.prototype, 'duration', {
configurable: true,
value: 4,
});
createdMediaElements = [];
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation(
(tagName, options) => {
const element = originalCreateElement(tagName, options);
if (tagName === 'video' || tagName === 'audio') {
createdMediaElements.push(element as HTMLMediaElement);
}
return element;
},
);
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:media'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
() => undefined,
);
});
it('opens login before creating placeholders and resumes the same upload after login', async () => {
const openEditorLoginModal = vi.fn();
const { rerender } = render(
<UploadWorkflowHarness
canAccessProtectedData={false}
openEditorLoginModal={openEditorLoginModal}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传素材' }));
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('assets').textContent).toBe('');
const resumeUpload = openEditorLoginModal.mock.calls[0]?.[0];
rerender(
<UploadWorkflowHarness
canAccessProtectedData
openEditorLoginModal={openEditorLoginModal}
/>,
);
act(() => {
(resumeUpload as () => void)();
});
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'persisted-上传素材.png:上传素材.png:folder-uploaded:ready:-',
);
});
expect(createEditorAssetFolderMock).toHaveBeenCalledWith('上传素材', 101);
});
it('opens login instead of the asset file picker when protected data is unavailable', () => {
const openEditorLoginModal = vi.fn();
render(
<UploadWorkflowHarness
canAccessProtectedData={false}
openEditorLoginModal={openEditorLoginModal}
/>,
);
const uploadInput = screen.getByLabelText(
'上传图片文件',
) as HTMLInputElement;
const clickUploadInput = vi.spyOn(uploadInput, 'click');
fireEvent.click(screen.getByRole('button', { name: '请求素材上传' }));
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(clickUploadInput).not.toHaveBeenCalled();
});
it('opens login instead of the reference file picker when protected data is unavailable', () => {
const openEditorLoginModal = vi.fn();
render(
<UploadWorkflowHarness
canAccessProtectedData={false}
openEditorLoginModal={openEditorLoginModal}
/>,
);
const uploadInput = screen.getByLabelText(
'上传图片文件',
) as HTMLInputElement;
const clickUploadInput = vi.spyOn(uploadInput, 'click');
fireEvent.click(screen.getByRole('button', { name: '请求角色规范上传' }));
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(clickUploadInput).not.toHaveBeenCalled();
});
it('creates an uploading asset card, adds a canvas layer, keeps the sidebar, and patches the layer with the persisted asset id', async () => {
const deferredAsset = createDeferred<{
assetId: string;
folderId: string;
label: string;
imageSrc: string;
width: number;
height: number;
sourceType: 'uploaded';
objectKey: string;
assetObjectId: string;
}>();
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '上传到画布' }));
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'upload-1:画布素材.png:folder-uploaded:uploading:上传中',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png',
);
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '画布素材.png' }),
'image',
);
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/画布素材.png',
assetObjectId: 'assetobj-editor-image',
}),
);
expect(screen.getByTestId('sidebar').textContent).toBe('assets');
expect(screen.getByTestId('selected-layer').textContent).toBe(
'layer-upload-1',
);
deferredAsset.resolve({
assetId: 'asset-persisted-canvas',
folderId: 'folder-uploaded',
label: '画布素材.png',
imageSrc: 'data:image/png;base64,Y2FudmFz',
width: 420,
height: 315,
sourceType: 'uploaded',
objectKey: 'object-key-canvas',
assetObjectId: 'asset-object-canvas',
});
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toContain(
'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5',
);
});
});
it('uses the uploaded image resolution before creating the canvas layer and persisted asset', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '上传原始尺寸到画布' }));
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '原始尺寸素材.png',
width: 1536,
height: 1024,
}),
);
});
expect(screen.getByTestId('assets').textContent).toContain(
'persisted-原始尺寸素材.png:原始尺寸素材.png:folder-uploaded:ready:-:1536x1024',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:原始尺寸素材.png:persisted-原始尺寸素材.png:-718:-462:1536x1024',
);
});
it('uses the uploaded video resolution before creating the canvas layer and persisted asset', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '上传视频到画布' }));
await waitFor(() => {
expect(
createdMediaElements.some(
(element) => element.tagName.toLowerCase() === 'video',
),
).toBe(true);
});
createdMediaElements
.filter((element) => element.tagName.toLowerCase() === 'video')
.forEach((element) => setVideoElementSize(element, 1280, 720));
act(() => {
createdMediaElements.forEach((element) =>
element.onloadedmetadata?.(new Event('loadedmetadata')),
);
});
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '画布视频.mp4',
width: 1280,
height: 720,
durationSeconds: 4,
}),
);
});
expect(screen.getByTestId('assets').textContent).toContain(
'persisted-画布视频.mp4:画布视频.mp4:folder-uploaded:ready:-:1280x720',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布视频.mp4:persisted-画布视频.mp4:-590:-310:1280x720',
);
});
it('marks upload cards as failed and reopens login on auth errors returned by asset creation', async () => {
const openEditorLoginModal = vi.fn();
createEditorAssetMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(
<UploadWorkflowHarness openEditorLoginModal={openEditorLoginModal} />,
);
fireEvent.click(screen.getByRole('button', { name: '上传素材' }));
await waitFor(() => {
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('assets').textContent).toContain(
'upload-1:上传素材.png:folder-uploaded:failed:请先登录',
);
});
});
it('dispatches file input uploads to generation references and resets failed state', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'character:failed:-:0:-:-:0:-:-',
);
});
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('角色规范.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'character:idle:角色规范.png:0:-:-',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/角色规范.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/角色规范.png',
assetObjectId: 'assetobj-editor-image',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('dispatches file input uploads to normal generation references', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('普通参考.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'generate:idle:-:0:-:-:1:普通参考.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/普通参考.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/普通参考.png',
assetObjectId: 'assetobj-editor-image',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('dispatches file input uploads to UI asset extraction references', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(
screen.getByRole('button', { name: '选择UI素材提取参考图' }),
);
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('UI素材参考.png')],
},
});
await waitFor(() => {
expect(
screen.getByTestId('ui-asset-extraction-references').textContent,
).toContain(
'UI素材参考.png:/generated-character-drafts/editor/asset-library/image/UI素材参考.png:generated-character-drafts/editor/asset-library/image/UI素材参考.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/UI素材参考.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/UI素材参考.png',
assetObjectId: 'assetobj-editor-image',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('dispatches file input uploads to spec reference images', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备规范生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('UI参考.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'spec:idle:-:0:-:UI参考.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/UI参考.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/UI参考.png',
assetObjectId: 'assetobj-editor-image',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('uploads Seedance reference videos through OSS and stores object keys instead of data URLs', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备视频生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择参考视频' }));
const uploadInput = screen.getByLabelText('上传图片文件');
fireEvent.change(uploadInput, {
target: {
files: [new File(['video'], '动作参考.mp4', { type: 'video/mp4' })],
},
});
act(() => {
createdMediaElements[0]?.onloadedmetadata?.(new Event('loadedmetadata'));
});
await waitFor(() => {
expect(uploadEditorSeedanceReferenceFileMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '动作参考.mp4' }),
'video',
);
expect(screen.getByTestId('dialog').textContent).toContain(
'video:idle:-:0:-:-:1:动作参考.mp4:video:https://signed.example.com/video/动作参考.mp4:generated-character-drafts/editor/seedance-references/video/动作参考.mp4',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'https://signed.example.com/video/动作参考.mp4',
objectKey:
'generated-character-drafts/editor/seedance-references/video/动作参考.mp4',
assetObjectId: 'assetobj-video',
width: 560,
height: 315,
sourceType: 'uploaded',
durationSeconds: 4,
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain(
'data:video',
);
});
it('uploads Seedance reference audios through OSS and stores object keys instead of data URLs', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备视频生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择参考音频' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [new File(['audio'], '旁白.mp3', { type: 'audio/mpeg' })],
},
});
act(() => {
createdMediaElements[0]?.onloadedmetadata?.(new Event('loadedmetadata'));
});
await waitFor(() => {
expect(uploadEditorSeedanceReferenceFileMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '旁白.mp3' }),
'audio',
);
expect(screen.getByTestId('dialog').textContent).toContain(
'video:idle:-:0:-:-:1:旁白.mp3:audio:https://signed.example.com/audio/旁白.mp3:generated-character-drafts/editor/seedance-references/audio/旁白.mp3',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc: 'https://signed.example.com/audio/旁白.mp3',
objectKey:
'generated-character-drafts/editor/seedance-references/audio/旁白.mp3',
assetObjectId: 'assetobj-audio',
width: 420,
height: 120,
sourceType: 'uploaded',
durationSeconds: 4,
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain(
'data:audio',
);
});
});