dify/web/app/components/base/image-uploader/image-list.spec.tsx
2026-02-24 21:29:28 +08:00

292 lines
10 KiB
TypeScript

import type { ImageFile } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import ImageList from './image-list'
const createLocalFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
type: TransferMethod.local_file,
_id: `local-${Date.now()}-${Math.random()}`,
fileId: 'file-id',
progress: 100,
url: '',
base64Url: 'data:image/png;base64,abc123',
...overrides,
})
const createRemoteFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
type: TransferMethod.remote_url,
_id: `remote-${Date.now()}-${Math.random()}`,
fileId: '',
progress: 100,
url: 'https://example.com/image.png',
...overrides,
})
describe('ImageList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing with empty list', () => {
render(<ImageList list={[]} />)
expect(screen.getByTestId('image-list')).toBeInTheDocument()
})
it('should render images for each item in the list', () => {
const list = [
createLocalFile({ _id: 'file-1' }),
createLocalFile({ _id: 'file-2' }),
]
render(<ImageList list={list} />)
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
})
it('should use base64Url as src for local files', () => {
const list = [createLocalFile({ _id: 'file-1', base64Url: 'data:image/png;base64,xyz' })]
render(<ImageList list={list} />)
expect(screen.getByRole('img')).toHaveAttribute('src', 'data:image/png;base64,xyz')
})
it('should use url as src for remote files', () => {
const list = [createRemoteFile({ _id: 'file-1', url: 'https://example.com/img.jpg' })]
render(<ImageList list={list} />)
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/img.jpg')
})
it('should set alt attribute from file name', () => {
const file = new File(['test'], 'my-image.png', { type: 'image/png' })
const list = [createLocalFile({ _id: 'file-1', file })]
render(<ImageList list={list} />)
expect(screen.getByRole('img')).toHaveAttribute('alt', 'my-image.png')
})
})
describe('Props', () => {
it('should show remove buttons when not readonly', () => {
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} onRemove={vi.fn()} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not show remove buttons when readonly', () => {
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} readonly onRemove={vi.fn()} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('Local File Progress', () => {
it('should show progress percentage when local file is uploading', () => {
const list = [createLocalFile({ _id: 'file-1', progress: 45 })]
render(<ImageList list={list} />)
expect(screen.getByText(/^45\s*%$/)).toBeInTheDocument()
})
it('should not show progress overlay when local file is complete', () => {
const list = [createLocalFile({ _id: 'file-1', progress: 100 })]
render(<ImageList list={list} />)
expect(screen.queryByText(/\d+\s*%/)).not.toBeInTheDocument()
})
it('should show retry icon when local file upload fails (progress -1)', () => {
const onReUpload = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: -1 })]
render(<ImageList list={list} onReUpload={onReUpload} />)
expect(screen.getByTestId('retry-icon')).toBeInTheDocument()
expect(screen.queryByText(/\d+\s*%/)).not.toBeInTheDocument()
})
})
describe('Remote URL Progress', () => {
it('should show loading spinner when remote file is loading (progress 0)', () => {
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
render(<ImageList list={list} />)
// Loading spinner has animate-spin class
expect(screen.getByTestId('image-loader')).toBeInTheDocument()
})
it('should not show loading state when remote file is loaded (progress 100)', () => {
const list = [createRemoteFile({ _id: 'file-1', progress: 100 })]
render(<ImageList list={list} />)
expect(screen.queryByTestId('image-loader')).not.toBeInTheDocument()
})
it('should show error indicator when remote file fails (progress -1)', () => {
const list = [createRemoteFile({ _id: 'file-1', progress: -1 })]
render(<ImageList list={list} />)
expect(screen.getByTestId('image-error-container')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onRemove when remove button is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} onRemove={onRemove} />)
await user.click(screen.getByRole('button'))
expect(onRemove).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledWith('file-1')
})
it('should call onReUpload when retry icon is clicked on failed local file', async () => {
const user = userEvent.setup()
const onReUpload = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: -1 })]
render(<ImageList list={list} onReUpload={onReUpload} />)
const retryIcon = screen.getByTestId('retry-icon')
await user.click(retryIcon)
expect(onReUpload).toHaveBeenCalledWith('file-1')
})
it('should open image preview when clicking a completed image', async () => {
const user = userEvent.setup()
const list = [createRemoteFile({ _id: 'file-1', progress: 100, url: 'https://example.com/img.png' })]
render(<ImageList list={list} />)
await user.click(screen.getByRole('img'))
const preview = screen.getByTestId('image-preview-container')
expect(preview).toBeInTheDocument()
})
it('should not open image preview when clicking an in-progress image', async () => {
const user = userEvent.setup()
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
render(<ImageList list={list} />)
await user.click(screen.getByRole('img'))
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()
})
it('should close image preview when cancel is clicked', async () => {
const user = userEvent.setup()
const list = [createRemoteFile({ _id: 'file-1', progress: 100 })]
render(<ImageList list={list} />)
// Open preview
await user.click(screen.getByRole('img'))
expect(screen.queryByTestId('image-preview-container')).toBeInTheDocument()
// Close preview
const closeButton = screen.getByTestId('image-preview-close-button')
await user.click(closeButton)
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()
})
it('should open preview with base64Url for completed local file', async () => {
const user = userEvent.setup()
const list = [createLocalFile({ _id: 'file-1', progress: 100, base64Url: 'data:image/png;base64,localdata' })]
render(<ImageList list={list} />)
await user.click(screen.getByRole('img'))
const previewImage = screen.getByTestId('image-preview-image')
expect(previewImage).toBeInTheDocument()
expect(previewImage).toHaveAttribute('src', 'data:image/png;base64,localdata')
})
})
describe('Image Load Events', () => {
it('should call onImageLinkLoadSuccess for remote URL on load when progress is not -1', () => {
const onImageLinkLoadSuccess = vi.fn()
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
const img = screen.getByRole('img')
fireEvent.load(img)
expect(onImageLinkLoadSuccess).toHaveBeenCalledWith('file-1')
})
it('should not call onImageLinkLoadSuccess for remote URL when progress is -1', () => {
const onImageLinkLoadSuccess = vi.fn()
const list = [createRemoteFile({ _id: 'file-1', progress: -1 })]
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
const img = screen.getByRole('img')
fireEvent.load(img)
expect(onImageLinkLoadSuccess).not.toHaveBeenCalled()
})
it('should not call onImageLinkLoadSuccess for local file type', () => {
const onImageLinkLoadSuccess = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
const img = screen.getByRole('img')
fireEvent.load(img)
expect(onImageLinkLoadSuccess).not.toHaveBeenCalled()
})
it('should call onImageLinkLoadError for remote URL on error', () => {
const onImageLinkLoadError = vi.fn()
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
render(<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />)
const img = screen.getByRole('img')
fireEvent.error(img)
expect(onImageLinkLoadError).toHaveBeenCalledWith('file-1')
})
it('should not call onImageLinkLoadError for local file type', () => {
const onImageLinkLoadError = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
render(<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />)
const img = screen.getByRole('img')
fireEvent.error(img)
expect(onImageLinkLoadError).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle list with mixed local and remote files', () => {
const list = [
createLocalFile({ _id: 'local-1' }),
createRemoteFile({ _id: 'remote-1' }),
]
render(<ImageList list={list} />)
expect(screen.getAllByRole('img')).toHaveLength(2)
})
it('should handle item without file property for alt attribute', () => {
const list = [createLocalFile({ _id: 'file-1', file: undefined })]
render(<ImageList list={list} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
})
it('should handle onRemove not provided gracefully', async () => {
const user = userEvent.setup()
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} />)
// Button exists, clicking it should not throw
await user.click(screen.getByRole('button'))
})
})
})