8abeb333bb
- 使用白名单控制快速编辑功能的显示 - 同步浮动工具栏、右键菜单、打开入口和提交门禁 - 禁用角色动作序列帧(整体)、音频和单个图标(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>
1421 lines
47 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|