/* @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 (
{imageSrc ? ( {alt} ) : null} {previewOverlay}
); }, })); 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 { 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(), 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; 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(), }; } 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('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( , ); 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( '[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); }); });