Files
Genarrative/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
T
k88936 8abeb333bb
Project CI / Repository checks (push) Successful in 56s
Project CI / Frontend tests (push) Successful in 4m1s
Project CI / Backend tests (push) Successful in 4m26s
Project CI / Native shell tests (push) Successful in 15m12s
统一画布快速编辑白名单与入口行为 (#140)
- 使用白名单控制快速编辑功能的显示

- 同步浮动工具栏、右键菜单、打开入口和提交门禁
- 禁用角色动作序列帧(整体)、音频和单个图标(explain: backend reject)的快速编辑

- 把快速编辑api改为只接受resource Id | asset Id,删除 image_src 参数, 以保证校验

其中包含生产数据迁移。迁移用于清理历史普通图片错误持久化的 assetKind="image",覆盖 asset → project-resource → showcase → canvas 四个作用域,并包含 operator 鉴权、分批 dry-run、批次 SHA-256 绑定 apply、active 结构化画
  > 布迁移摘要同步及最终零残留复核。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/140
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-12 14:46:24 +08:00

1421 lines
47 KiB
TypeScript

/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useRef, useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasLayer,
CanvasTool,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
type ImageReferenceUploadCapacity,
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',
resolveImageReferenceUploadCapacity = () => null,
refreshAssetLibrary,
}: {
canAccessProtectedData?: boolean;
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
activeTool?: CanvasTool;
projectId?: string | null;
resolveImageReferenceUploadCapacity?: (
target: UploadTarget,
) => ImageReferenceUploadCapacity | null;
refreshAssetLibrary?: () => Promise<unknown> | void;
}) {
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,
resolveImageReferenceUploadCapacity,
appendUiAssetExtractionReferences: (references) =>
setUiAssetExtractionReferences((currentReferences) => [
...currentReferences,
...references,
]),
appendCanvasLayersWithResources: (nextLayers) => {
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
captureCanvasHistory: vi.fn(),
selectSingleLayer: setSelectedLayerId,
refreshAssetLibrary,
});
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}:${asset.src}`,
)
.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 ?? '-'}:${layer.src}`,
)
.join('|')}
</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
<span data-testid="pending-reference-uploads">
{workflow.pendingImageReferenceUploadCount}
</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: 'icon',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
})
}
>
准备规范生成
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'icon',
prompt: '',
status: 'idle',
})
}
>
准备图标素材生成
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'video',
prompt: '',
status: 'failed',
generationReferences: [],
})
}
>
准备视频生成
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'scene',
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.setUploadTarget('icon-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', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
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',
generationInputs: input.generationInputs,
}));
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,
}),
);
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 measuredImageSources: string[] = [];
vi.stubGlobal(
'Image',
class {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
set src(value: string) {
measuredImageSources.push(value);
}
},
);
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:上传中:420x315:https://signed.example.com/editor/image/画布素材.png',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png:https://signed.example.com/editor/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(measuredImageSources).toContain(
'https://signed.example.com/editor/image/画布素材.png',
);
expect(measuredImageSources).not.toContain(
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
);
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:
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
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:-:420x315:https://signed.example.com/editor/image/画布素材.png',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5:420x315:object-key-canvas:https://signed.example.com/editor/image/画布素材.png',
);
});
});
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,
generationInputs: {
fields: [{ title: '时长', value: '4秒' }],
references: [],
},
}),
);
});
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('persists all files selected for scene references', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备场景生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [
createTestFile('场景参考一.png'),
createTestFile('场景参考二.png'),
],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'scene:idle:-:0:-:-:2:场景参考一.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/场景参考一.png',
}),
);
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/场景参考二.png',
}),
);
});
it('rejects reference overflow before reading or uploading selected files', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(
<UploadWorkflowHarness
resolveImageReferenceUploadCapacity={(target) =>
target === 'generation-reference'
? { label: '生成参考图', currentCount: 0, limit: 5 }
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: Array.from({ length: 6 }, (_, index) =>
createTestFile(`超限参考图-${index + 1}.png`),
),
},
});
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
'生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 5 张',
);
});
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('counts in-flight reservations when overlapping reference batches are selected', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const uploadGate = createDeferred<void>();
uploadEditorMediaAssetFileMock.mockImplementation(async (file: File) => {
await uploadGate.promise;
return {
src: `https://signed.example.com/editor/image/${file.name}`,
legacyPublicPath: `/generated-character-drafts/editor/asset-library/image/${file.name}`,
objectKey: `generated-character-drafts/editor/asset-library/image/${file.name}`,
assetObjectId: 'assetobj-editor-image',
};
});
render(
<UploadWorkflowHarness
resolveImageReferenceUploadCapacity={(target) =>
target === 'generation-reference'
? { label: '生成参考图', currentCount: 0, limit: 5 }
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: Array.from({ length: 3 }, (_, index) =>
createTestFile(`第一批-${index + 1}.png`),
),
},
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(3);
expect(screen.getByTestId('pending-reference-uploads').textContent).toBe(
'3',
);
});
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: Array.from({ length: 3 }, (_, index) =>
createTestFile(`第二批-${index + 1}.png`),
),
},
});
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
'生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 2 张',
);
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(3);
await act(async () => {
uploadGate.resolve();
await uploadGate.promise;
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'generate:idle:-:0:-:-:3',
);
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(3);
expect(screen.getByTestId('pending-reference-uploads').textContent).toBe(
'0',
);
});
});
it('rejects an in-flight upload when the original dialog context changes', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const uploadGate = createDeferred<void>();
let contextId = 'dialog-1';
uploadEditorMediaAssetFileMock.mockImplementation(async (file: File) => {
await uploadGate.promise;
return {
src: `https://signed.example.com/editor/image/${file.name}`,
legacyPublicPath: `/generated-character-drafts/editor/asset-library/image/${file.name}`,
objectKey: `generated-character-drafts/editor/asset-library/image/${file.name}`,
assetObjectId: 'assetobj-editor-image',
};
});
render(
<UploadWorkflowHarness
resolveImageReferenceUploadCapacity={(target) =>
target === 'generation-reference'
? {
label: '生成参考图',
currentCount: 0,
limit: 5,
contextId,
}
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: { files: [createTestFile('原面板参考图.png')] },
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1);
});
contextId = 'dialog-2';
await act(async () => {
uploadGate.resolve();
await uploadGate.promise;
});
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
'参考图上传目标已关闭或切换,本次结果不会添加到其他面板',
);
expect(screen.getByTestId('dialog').textContent).toContain(
'generate:failed:-:0:-:-:0',
);
expect(screen.getByTestId('pending-reference-uploads').textContent).toBe(
'0',
);
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('keeps successful references visible when part of a batch fails', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
createEditorProjectResourceMock.mockImplementation(
async (projectId, input) => {
if (input.imageSrc.includes('失败')) {
throw new Error('resource create failed');
}
return {
resourceId: `resource-${input.objectKey}`,
projectId,
imageSrc: input.imageSrc,
width: input.width,
height: input.height,
sourceType: input.sourceType,
objectKey: input.objectKey,
assetObjectId: input.assetObjectId,
};
},
);
render(
<UploadWorkflowHarness
refreshAssetLibrary={refreshAssetLibrary}
resolveImageReferenceUploadCapacity={(target) =>
target === 'generation-reference'
? {
label: '生成参考图',
currentCount: 0,
limit: 5,
contextId: 'dialog-1',
}
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [
createTestFile('成功参考图.png'),
createTestFile('失败参考图.png'),
],
},
});
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
'部分参考图保存失败,已保留成功的 1 张',
);
expect(screen.getByTestId('dialog').textContent).toContain(
'generate:idle:-:0:-:-:1:成功参考图.png',
);
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('pending-reference-uploads').textContent).toBe(
'0',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
it('rechecks the latest capacity before persisting after a model shrinks', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
let capacityChecks = 0;
render(
<UploadWorkflowHarness
resolveImageReferenceUploadCapacity={(target) =>
target === 'generation-reference'
? {
label: '生成参考图',
currentCount: 0,
limit: ++capacityChecks === 1 ? 14 : 5,
}
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: Array.from({ length: 6 }, (_, index) =>
createTestFile(`缩容参考图-${index + 1}.png`),
),
},
});
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
'生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 5 张',
);
});
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('rechecks capacity after object upload and before creating project resources', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
let capacityChecks = 0;
render(
<UploadWorkflowHarness
resolveImageReferenceUploadCapacity={(target) =>
target === 'generation-reference'
? {
label: '生成参考图',
currentCount: 0,
limit: ++capacityChecks <= 2 ? 14 : 5,
}
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备图片生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: Array.from({ length: 6 }, (_, index) =>
createTestFile(`持久化前缩容-${index + 1}.png`),
),
},
});
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
'生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 5 张',
);
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(6);
expect(createEditorProjectResourceMock).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('persists uploaded icon specs as icon-spec project resources', 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(
'icon:idle:-:0:上传图标规范.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('persists uploaded icon specs as icon-spec assets without a project', async () => {
render(<UploadWorkflowHarness projectId={null} />);
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(
'icon:idle:-:0:素材库图标规范.png',
);
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('reserves the single spec reference slot across overlapping uploads', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const uploadGate = createDeferred<void>();
uploadEditorMediaAssetFileMock.mockImplementation(async (file: File) => {
await uploadGate.promise;
return {
src: `https://signed.example.com/editor/image/${file.name}`,
legacyPublicPath: `/generated-character-drafts/editor/asset-library/image/${file.name}`,
objectKey: `generated-character-drafts/editor/asset-library/image/${file.name}`,
assetObjectId: 'assetobj-editor-image',
};
});
render(
<UploadWorkflowHarness
resolveImageReferenceUploadCapacity={(target) =>
target === 'spec-reference'
? {
label: '规范参考图',
currentCount: 0,
limit: 1,
contextId: 'dialog-spec',
}
: null
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '准备规范生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: { files: [createTestFile('第一张规范.png')] },
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('pending-reference-uploads').textContent).toBe(
'1',
);
});
fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: { files: [createTestFile('第二张规范.png')] },
});
expect(alertMock).toHaveBeenCalledWith(
'规范参考图最多允许 1 张,当前已有 0 张,本次最多还能选择 0 张',
);
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1);
await act(async () => {
uploadGate.resolve();
await uploadGate.promise;
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'spec:idle:-:0:-:第一张规范.png',
);
expect(screen.getByTestId('pending-reference-uploads').textContent).toBe(
'0',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(1);
});
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',
generationInputs: {
fields: [{ title: '时长', value: '4秒' }],
references: [],
},
}),
);
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',
generationInputs: {
fields: [{ title: '时长', value: '4秒' }],
references: [],
},
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain(
'data:audio',
);
});
});