a753ac4cd8
- buildModel3dSubmissionPlan 接收当前素材夹与素材名,与图片/图标/音效等工具一样同时发 projectId + assetFolderId + assetLabel(素材名缺省回落本次结果标题),没有素材夹时只发 projectId。
- useModel3dGenerationTask 新增 assetFolderId 入参,画布生成链路把当前素材夹透传下来;结果仍走既有画布路径读回,刷新素材库沿用 applyGeneratedProjectSnapshot 的既有行为,不新增 3D 专用刷新。
- 素材库行加 model3d 分支:缩略图按预览图(asset.src)渲染,objectKey(模型本体 .glb)不参与图片签名,与画布 3D 卡片的 objectKey={null} 口径一致,避免素材行去加载模型文件。
- 用例补齐:请求体同时带两个落点、无素材夹时不带 assetFolderId/assetLabel、素材夹只 trim 且素材名回落标题,以及素材行 3D 预览不签模型对象键 / 普通图片仍按 objectKey 换签名地址。
- 验证:npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/ImageCanvasAssetRowView.test.tsx(67 passed)、npm run typecheck、check:encoding / git diff --check 通过。
554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import type { ReactNode } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
ImageCanvasAssetRowView,
|
|
type ImageCanvasAssetRowViewProps,
|
|
} from './ImageCanvasAssetRowView';
|
|
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
|
|
|
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
|
useResolvedAssetReadUrl: useResolvedAssetReadUrlMock,
|
|
}));
|
|
|
|
vi.mock('../common/PlatformMediaFrame', () => ({
|
|
PlatformMediaFrame: ({
|
|
src,
|
|
objectKey,
|
|
alt,
|
|
previewOverlay,
|
|
}: {
|
|
src?: string | null;
|
|
objectKey?: string | null;
|
|
alt: string;
|
|
previewOverlay?: ReactNode;
|
|
}) => {
|
|
const imageSrc =
|
|
src?.trim() ||
|
|
(objectKey?.trim()
|
|
? `https://signed.example.com/${objectKey.trim()}`
|
|
: '');
|
|
return (
|
|
<div className="platform-media-frame">
|
|
{imageSrc ? (
|
|
<img
|
|
src={imageSrc}
|
|
alt={alt}
|
|
data-object-key={objectKey?.trim() || undefined}
|
|
/>
|
|
) : null}
|
|
{previewOverlay}
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
useResolvedAssetReadUrlMock.mockImplementation(
|
|
(
|
|
source: string | null | undefined,
|
|
options?: { objectKey?: string | null },
|
|
) => ({
|
|
resolvedUrl: options?.objectKey
|
|
? `https://signed.example.com/${options.objectKey}`
|
|
: (source ?? ''),
|
|
isResolving: false,
|
|
shouldResolve: Boolean(options?.objectKey),
|
|
}),
|
|
);
|
|
});
|
|
|
|
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
|
|
return {
|
|
id: 'asset-1',
|
|
label: '账号素材A',
|
|
src: '/creation-type-references/puzzle.webp',
|
|
width: 640,
|
|
height: 512,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderAssetRow({
|
|
asset = createAsset(),
|
|
isAssetSelectionMode = false,
|
|
selectedAssetIds = new Set<string>(),
|
|
renamingAsset = null,
|
|
suppressAssetClick = false,
|
|
addAssetLayer = vi.fn(),
|
|
toggleAssetSelected = vi.fn(),
|
|
commitAssetRename = vi.fn(),
|
|
deleteUploadedAsset = vi.fn(),
|
|
submitAssetToShowcase = vi.fn(),
|
|
onDownloadAsset = vi.fn(),
|
|
}: {
|
|
asset?: EditorAsset;
|
|
isAssetSelectionMode?: boolean;
|
|
selectedAssetIds?: Set<string>;
|
|
renamingAsset?: { assetId: string; value: string } | null;
|
|
suppressAssetClick?: boolean;
|
|
addAssetLayer?: (asset: EditorAsset) => void;
|
|
toggleAssetSelected?: ImageCanvasAssetRowViewProps['toggleAssetSelected'];
|
|
commitAssetRename?: (asset: EditorAsset) => void;
|
|
deleteUploadedAsset?: (asset: EditorAsset) => void;
|
|
submitAssetToShowcase?: (asset: EditorAsset) => void;
|
|
onDownloadAsset?: (asset: EditorAsset) => void;
|
|
} = {}) {
|
|
const props = {
|
|
asset,
|
|
assetPointerDragRef: { current: null },
|
|
suppressAssetClickRef: { current: suppressAssetClick },
|
|
isAssetSelectionMode,
|
|
selectedAssetIds,
|
|
visibleAssetIds: [asset.id],
|
|
renamingAsset,
|
|
setRenamingAsset: vi.fn(),
|
|
setUploadDropTarget: vi.fn(),
|
|
setAssetPointerDrag: vi.fn(),
|
|
updateAssetMoveDropFolder: vi.fn(),
|
|
addUploadedFiles: vi.fn(),
|
|
moveAssetToFolder: vi.fn(),
|
|
startRenamingAsset: vi.fn(),
|
|
commitAssetRename,
|
|
deleteUploadedAsset,
|
|
submitAssetToShowcase,
|
|
toggleAssetSelected,
|
|
addAssetLayer,
|
|
onDownloadAsset,
|
|
};
|
|
|
|
return {
|
|
props,
|
|
...render(<ImageCanvasAssetRowView {...props} />),
|
|
};
|
|
}
|
|
|
|
describe('ImageCanvasAssetRowView', () => {
|
|
it('adds the asset to the canvas in normal mode', () => {
|
|
const addAssetLayer = vi.fn();
|
|
const asset = createAsset();
|
|
renderAssetRow({ asset, addAssetLayer });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
|
|
|
|
expect(addAssetLayer).toHaveBeenCalledWith(asset);
|
|
});
|
|
|
|
it('downloads the asset from the row actions', () => {
|
|
const asset = createAsset();
|
|
const onDownloadAsset = vi.fn();
|
|
renderAssetRow({ asset, onDownloadAsset });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
|
|
|
|
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
|
|
});
|
|
|
|
it('3D 素材用预览图渲染,不拿模型对象去换签名地址', () => {
|
|
const asset = createAsset({
|
|
label: '木椅',
|
|
assetKind: 'model3d',
|
|
mediaType: 'image',
|
|
src: '/genarrative-assets/editor/model3d/task-1/preview.webp',
|
|
objectKey: 'genarrative-assets/editor/model3d/task-1/model.glb',
|
|
});
|
|
renderAssetRow({ asset });
|
|
|
|
const preview = screen.getByAltText('素材:木椅');
|
|
expect(preview.getAttribute('src')).toBe(
|
|
'/genarrative-assets/editor/model3d/task-1/preview.webp',
|
|
);
|
|
// objectKey 是模型本体,带上它会让缩略图去加载 .glb。
|
|
expect(preview.getAttribute('data-object-key')).toBeNull();
|
|
});
|
|
|
|
it('普通图片素材仍按 objectKey 换签名地址', () => {
|
|
const asset = createAsset({
|
|
mediaType: 'image',
|
|
objectKey: 'genarrative-assets/editor/images/account-a.webp',
|
|
});
|
|
renderAssetRow({ asset });
|
|
|
|
expect(
|
|
screen.getByAltText('素材:账号素材A').getAttribute('data-object-key'),
|
|
).toBe('genarrative-assets/editor/images/account-a.webp');
|
|
});
|
|
|
|
it('closes the right click menu when row actions run on the same asset', () => {
|
|
const asset = createAsset({
|
|
sourceResourceId: 'resource-asset-1',
|
|
publicShowcaseEnabled: true,
|
|
});
|
|
const addAssetLayer = vi.fn();
|
|
const onDownloadAsset = vi.fn();
|
|
renderAssetRow({ asset, addAssetLayer, onDownloadAsset });
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
expect(screen.getByRole('menu')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
|
|
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
|
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
|
|
expect(addAssetLayer).toHaveBeenCalledWith(asset);
|
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
});
|
|
|
|
it('closes the right click menu when selection mode becomes active', () => {
|
|
const { props, rerender } = renderAssetRow();
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
expect(screen.getByRole('menu')).toBeTruthy();
|
|
|
|
rerender(
|
|
<ImageCanvasAssetRowView {...props} isAssetSelectionMode={true} />,
|
|
);
|
|
|
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
});
|
|
|
|
it('selects the asset instead of adding it in selection mode', () => {
|
|
const addAssetLayer = vi.fn();
|
|
const toggleAssetSelected = vi.fn();
|
|
renderAssetRow({
|
|
isAssetSelectionMode: true,
|
|
addAssetLayer,
|
|
toggleAssetSelected,
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
|
|
|
|
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
|
|
visibleAssetIds: ['asset-1'],
|
|
shiftKey: false,
|
|
});
|
|
expect(addAssetLayer).not.toHaveBeenCalled();
|
|
expect(
|
|
screen.queryByRole('button', { name: '下载素材账号素材A' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('disables rows that are outside the shared selectable-asset boundary', () => {
|
|
const toggleAssetSelected = vi.fn();
|
|
renderAssetRow({
|
|
asset: createAsset({ sourceKind: 'built-in' }),
|
|
isAssetSelectionMode: true,
|
|
toggleAssetSelected,
|
|
});
|
|
|
|
const button = screen.getByRole('button', {
|
|
name: '素材账号素材A不可选择',
|
|
}) as HTMLButtonElement;
|
|
expect(button.disabled).toBe(true);
|
|
fireEvent.click(screen.getByText('账号素材A'));
|
|
expect(toggleAssetSelected).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forwards only the shift modifier used by selection semantics', () => {
|
|
const toggleAssetSelected = vi.fn();
|
|
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
|
|
fireEvent.click(screen.getByText('账号素材A'), {
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
});
|
|
|
|
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
|
|
visibleAssetIds: ['asset-1'],
|
|
shiftKey: true,
|
|
});
|
|
});
|
|
|
|
it('blocks internal moves and external file drops in selection mode', () => {
|
|
const { container, props } = renderAssetRow({
|
|
isAssetSelectionMode: true,
|
|
});
|
|
const row = container.querySelector<HTMLElement>(
|
|
'[data-asset-id="asset-1"]',
|
|
);
|
|
if (!row) {
|
|
throw new Error('asset row should exist');
|
|
}
|
|
const fileTransfer = {
|
|
types: ['Files'],
|
|
files: [new File(['image'], '新增素材.png', { type: 'image/png' })],
|
|
dropEffect: 'copy',
|
|
getData: vi.fn(() => ''),
|
|
};
|
|
fireEvent.dragOver(row, { dataTransfer: fileTransfer });
|
|
fireEvent.drop(row, { dataTransfer: fileTransfer });
|
|
|
|
const assetTransfer = {
|
|
types: ['application/x-genarrative-editor-asset'],
|
|
files: [],
|
|
dropEffect: 'move',
|
|
getData: vi.fn(() => 'asset-other'),
|
|
};
|
|
fireEvent.dragOver(row, { dataTransfer: assetTransfer });
|
|
fireEvent.drop(row, { dataTransfer: assetTransfer });
|
|
|
|
expect(props.addUploadedFiles).not.toHaveBeenCalled();
|
|
expect(props.moveAssetToFolder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders rename input and commits with Enter', () => {
|
|
const commitAssetRename = vi.fn();
|
|
renderAssetRow({
|
|
renamingAsset: { assetId: 'asset-1', value: '主视觉素材' },
|
|
commitAssetRename,
|
|
});
|
|
|
|
const input = screen.getByLabelText('重命名素材账号素材A');
|
|
fireEvent.keyDown(input, { key: 'Enter' });
|
|
|
|
expect(input.className).toContain('platform-text-field');
|
|
expect(input.className).toContain(
|
|
'image-canvas-editor__asset-rename-input',
|
|
);
|
|
expect(commitAssetRename).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'asset-1' }),
|
|
);
|
|
});
|
|
|
|
it('keeps uploading assets disabled and shows progress', () => {
|
|
const addAssetLayer = vi.fn();
|
|
renderAssetRow({
|
|
asset: createAsset({
|
|
uploadStatus: 'uploading',
|
|
uploadProgress: 42,
|
|
uploadMessage: '正在上传',
|
|
}),
|
|
addAssetLayer,
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '上传中账号素材A' }));
|
|
|
|
expect(addAssetLayer).not.toHaveBeenCalled();
|
|
expect(screen.getAllByText('正在上传').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('42%').length).toBeGreaterThan(0);
|
|
expect(
|
|
screen.getByLabelText('素材账号素材A上传进度').getAttribute('value'),
|
|
).toBe('42');
|
|
});
|
|
|
|
it('uses the audio cover thumbnail for audio assets', () => {
|
|
renderAssetRow({
|
|
asset: createAsset({
|
|
label: '胜利音效.mp3',
|
|
src: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
|
mediaType: 'audio',
|
|
}),
|
|
});
|
|
|
|
expect(screen.getByText('音频')).toBeTruthy();
|
|
const image = screen.getByRole('img', { name: '素材:胜利音效.mp3' });
|
|
expect(image.getAttribute('src')).toBe(
|
|
'/creation-home/audio-asset-cover.png',
|
|
);
|
|
expect(
|
|
document.querySelector('.image-canvas-editor__asset-media-overlay'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('uses a stable visual preview for video asset cards without image thumbnails', () => {
|
|
renderAssetRow({
|
|
asset: createAsset({
|
|
label: '生成视频.mp4',
|
|
src: '/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
|
mediaType: 'video',
|
|
}),
|
|
});
|
|
|
|
expect(screen.getByText('视频')).toBeTruthy();
|
|
expect(
|
|
screen.queryByRole('img', { name: '素材:生成视频.mp4' }),
|
|
).toBeNull();
|
|
const video = screen.getByLabelText(
|
|
'视频素材预览:生成视频.mp4',
|
|
) as HTMLVideoElement;
|
|
expect(video.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
|
);
|
|
});
|
|
|
|
it('passes objectKey to image thumbnails in the asset library', () => {
|
|
renderAssetRow({
|
|
asset: createAsset({
|
|
label: '规范图',
|
|
src: '/generated-character-drafts/editor/spec.png',
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
mediaType: 'image',
|
|
}),
|
|
});
|
|
|
|
const image = screen.getByRole('img', { name: '素材:规范图' });
|
|
|
|
expect(image.getAttribute('data-object-key')).toBe(
|
|
'generated-character-drafts/editor/spec.png',
|
|
);
|
|
});
|
|
|
|
it('renders image thumbnails when the persisted asset only has objectKey', () => {
|
|
renderAssetRow({
|
|
asset: createAsset({
|
|
label: '规范图',
|
|
src: '',
|
|
objectKey: 'generated-character-drafts/editor/spec.png',
|
|
mediaType: 'image',
|
|
}),
|
|
});
|
|
|
|
const image = screen.getByRole('img', { name: '素材:规范图' });
|
|
|
|
expect(image.getAttribute('src')).toBe(
|
|
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
|
);
|
|
});
|
|
|
|
it('opens asset actions from the right click menu', () => {
|
|
const asset = createAsset({
|
|
sourceType: 'generated',
|
|
});
|
|
const deleteUploadedAsset = vi.fn();
|
|
const submitAssetToShowcase = vi.fn();
|
|
renderAssetRow({
|
|
asset,
|
|
deleteUploadedAsset,
|
|
submitAssetToShowcase,
|
|
});
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: '删除素材账号素材A' }),
|
|
).toBeNull();
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
|
|
const submitMenuItem = screen.getByRole('menuitem', {
|
|
name: /提交精选审核/u,
|
|
});
|
|
|
|
fireEvent.click(submitMenuItem);
|
|
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
|
expect(deleteUploadedAsset).toHaveBeenCalledWith(asset);
|
|
});
|
|
|
|
it('disables showcase submit when the asset is not generated', () => {
|
|
const submitAssetToShowcase = vi.fn();
|
|
renderAssetRow({ submitAssetToShowcase });
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
|
|
const submitMenuItem = screen.getByRole('menuitem', {
|
|
name: /提交精选审核/u,
|
|
});
|
|
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true);
|
|
expect(screen.getByText('只有生成素材可以提交精选审核')).toBeTruthy();
|
|
|
|
fireEvent.click(submitMenuItem);
|
|
expect(submitAssetToShowcase).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows why generated assets cannot be submitted before they are saved', () => {
|
|
const submitAssetToShowcase = vi.fn();
|
|
renderAssetRow({
|
|
asset: createAsset({
|
|
sourceType: 'generated',
|
|
persisted: false,
|
|
}),
|
|
submitAssetToShowcase,
|
|
});
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
|
|
const submitMenuItem = screen.getByRole('menuitem', {
|
|
name: /提交精选审核/u,
|
|
});
|
|
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true);
|
|
expect(screen.getByText('素材保存完成后才能提交精选审核')).toBeTruthy();
|
|
|
|
fireEvent.click(submitMenuItem);
|
|
expect(submitAssetToShowcase).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('enables showcase submit for persisted generated assets', () => {
|
|
const asset = createAsset({
|
|
sourceType: 'generated',
|
|
});
|
|
const submitAssetToShowcase = vi.fn();
|
|
renderAssetRow({ asset, submitAssetToShowcase });
|
|
|
|
fireEvent.contextMenu(
|
|
screen.getByRole('button', { name: '添加账号素材A' }),
|
|
{
|
|
clientX: 120,
|
|
clientY: 80,
|
|
},
|
|
);
|
|
|
|
const submitMenuItem = screen.getByRole('menuitem', {
|
|
name: /提交精选审核/u,
|
|
});
|
|
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(false);
|
|
|
|
fireEvent.click(submitMenuItem);
|
|
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
|
|
});
|
|
});
|