test: add tests for base > image-uploader (#32416)

This commit is contained in:
Saumya Talwani
2026-02-24 18:59:28 +05:30
committed by GitHub
parent 0358925d7d
commit 00935fe526
18 changed files with 2676 additions and 33 deletions

View File

@@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AudioPreview from './audio-preview'
describe('AudioPreview', () => {
const defaultProps = {
url: 'https://example.com/audio.mp3',
title: 'Test Audio',
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AudioPreview {...defaultProps} />)
expect(screen.getByTestId('audio-element')).toBeInTheDocument()
})
it('should render audio element with controls', () => {
render(<AudioPreview {...defaultProps} />)
const audio = screen.getByTestId('audio-element')
expect(audio.tagName).toBe('AUDIO')
expect(audio).toHaveAttribute('controls')
})
it('should render source element with correct src', () => {
render(<AudioPreview {...defaultProps} />)
const source = screen.getByTestId('audio-element').querySelector('source')
expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3')
expect(source).toHaveAttribute('type', 'audio/mpeg')
})
it('should render close button', () => {
render(<AudioPreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-preview')
expect(closeBtn).toBeInTheDocument()
})
it('should render via portal into document.body', () => {
render(<AudioPreview {...defaultProps} />)
const overlay = screen.getByTestId('audio-preview-overlay')
expect(overlay).toBeInTheDocument()
expect(overlay.parentElement).toBe(document.body)
})
})
describe('Props', () => {
it('should set audio title from title prop', () => {
render(<AudioPreview {...defaultProps} title="My Song" />)
expect(screen.getByTitle('My Song')).toBeInTheDocument()
})
it('should set audio source from url prop', () => {
render(<AudioPreview {...defaultProps} url="https://example.com/song.mp3" />)
const source = screen.getByTestId('audio-element').querySelector('source')
expect(source).toHaveAttribute('src', 'https://example.com/song.mp3')
})
it('should set autoPlay to false', () => {
render(<AudioPreview {...defaultProps} />)
const audio = screen.getByTestId('audio-element') as HTMLAudioElement
expect(audio.autoplay).toBe(false)
})
it('should set preload to metadata', () => {
render(<AudioPreview {...defaultProps} />)
const audio = screen.getByTestId('audio-element')
expect(audio).toHaveAttribute('preload', 'metadata')
})
})
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(<AudioPreview {...defaultProps} onCancel={onCancel} />)
const closeBtn = screen.getByTestId('close-preview')
await user.click(closeBtn)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should not call onCancel when overlay background is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(<AudioPreview {...defaultProps} onCancel={onCancel} />)
const overlay = screen.getByTestId('audio-preview-overlay')
await user.click(overlay)
// Clicking the overlay backdrop should not trigger onCancel
expect(onCancel).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty url', () => {
render(<AudioPreview {...defaultProps} url="" />)
const source = screen.getByTestId('audio-element').querySelector('source')
expect(source).toBeInTheDocument()
})
it('should handle empty title', () => {
render(<AudioPreview {...defaultProps} title="" />)
const audio = screen.getByTestId('audio-element')
expect(audio).toBeInTheDocument()
expect(audio).toHaveAttribute('title', '')
})
})
})

View File

@@ -1,5 +1,4 @@
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { createPortal } from 'react-dom'
type AudioPreviewProps = {
@@ -13,9 +12,9 @@ const AudioPreview: FC<AudioPreviewProps> = ({
onCancel,
}) => {
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()} data-testid="audio-preview-overlay">
<div>
<audio controls title={title} autoPlay={false} preload="metadata">
<audio controls title={title} autoPlay={false} preload="metadata" data-testid="audio-element">
<source
type="audio/mpeg"
src={url}
@@ -26,8 +25,9 @@ const AudioPreview: FC<AudioPreviewProps> = ({
<div
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
data-testid="close-preview"
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-close-line h-4 w-4 text-gray-500" />
</div>
</div>,
document.body,

View File

@@ -0,0 +1,244 @@
import type { useLocalFileUploader } from './hooks'
import type { ImageFile, VisionSettings } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import ChatImageUploader from './chat-image-uploader'
type LocalUploaderArgs = Parameters<typeof useLocalFileUploader>[0]
const mocks = vi.hoisted(() => ({
hookArgs: undefined as LocalUploaderArgs | undefined,
handleLocalFileUpload: vi.fn<(file: File) => void>(),
}))
vi.mock('./hooks', () => ({
useLocalFileUploader: (args: LocalUploaderArgs) => {
mocks.hookArgs = args
return {
disabled: args.disabled ?? false,
handleLocalFileUpload: mocks.handleLocalFileUpload,
}
},
}))
const createSettings = (overrides: Partial<VisionSettings> = {}): VisionSettings => ({
enabled: true,
number_limits: 5,
detail: Resolution.high,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 10,
...overrides,
})
const queryFileInput = () => {
return screen.queryByTestId('local-file-input') as HTMLInputElement | null
}
const getFileInput = () => {
const input = queryFileInput()
if (!input)
throw new Error('Expected file input to exist')
return input
}
describe('ChatImageUploader', () => {
const defaultOnUpload = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mocks.hookArgs = undefined
mocks.handleLocalFileUpload.mockImplementation((file) => {
mocks.hookArgs?.onUpload({
type: TransferMethod.local_file,
_id: 'local-upload-id',
fileId: '',
progress: 0,
url: 'data:image/png;base64,mock',
file,
} as ImageFile)
})
})
describe('Rendering', () => {
it('should render UploadOnlyFromLocal when only local_file transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(queryFileInput()).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should render UploaderButton when remote_url is a transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render UploaderButton when both transfer methods are present', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass limit from image_file_size_limit to uploader hook', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 20,
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(mocks.hookArgs?.limit).toBe(20)
})
it('should convert string image_file_size_limit to number', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: '15',
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(mocks.hookArgs?.limit).toBe(15)
})
it('should pass disabled prop in local-only mode', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
expect(mocks.hookArgs?.disabled).toBe(true)
expect(getFileInput()).toBeDisabled()
})
it('should pass disabled prop in button mode', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
describe('User Interactions', () => {
it('should call onUpload when a local file is uploaded', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<ChatImageUploader settings={settings} onUpload={onUpload} />)
const input = getFileInput()
const file = new File(['hello'], 'demo.png', { type: 'image/png' })
await user.upload(input, file)
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(file)
expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({
type: TransferMethod.local_file,
}))
})
it('should open popover when uploader trigger is clicked', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should call onUpload when a remote image link is submitted', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={onUpload} />)
await user.click(screen.getByRole('button'))
await user.type(screen.getByTestId('image-link-input'), 'https://example.com/image.png')
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({
type: TransferMethod.remote_url,
url: 'https://example.com/image.png',
progress: 0,
}))
})
it('should not open popover when uploader trigger is disabled', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} disabled />)
await user.click(screen.getByRole('button'))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should show OR separator and local uploader when both methods are available', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/OR/i)).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(queryFileInput()).toBeInTheDocument()
})
it('should not show OR separator or local uploader when only remote_url method', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText(/OR/i)).not.toBeInTheDocument()
expect(queryFileInput()).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render UploaderButton for all transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.all],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render UploaderButton when transfer_methods is empty', () => {
const settings = createSettings({
transfer_methods: [],
})
render(<ChatImageUploader settings={settings} onUpload={defaultOnUpload} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@@ -2,8 +2,6 @@ import type { FC } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Upload03 } from '@/app/components/base/icons/src/vender/line/general'
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -33,7 +31,7 @@ const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
${hovering && 'bg-gray-100'}
`}
>
<ImagePlus className="h-4 w-4 text-gray-500" />
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
</div>
)}
</Uploader>
@@ -84,7 +82,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
disabled={disabled}
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
>
<ImagePlus className="h-4 w-4 text-gray-500" />
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
@@ -109,7 +107,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
hovering && 'bg-primary-50',
)}
>
<Upload03 className="mr-1 h-4 w-4" />
<span className="i-custom-vender-line-general-upload-03 mr-1 h-4 w-4" />
{t('imageUploader.uploadFromComputer', { ns: 'common' })}
</div>
)}

View File

@@ -0,0 +1,774 @@
import type { ClipboardEvent, DragEvent } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { Resolution, TransferMethod } from '@/types/app'
import { useClipboardUploader, useDraggableUploader, useImageFiles, useLocalFileUploader } from './hooks'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ token: undefined }),
}))
const { mockImageUpload, mockGetImageUploadErrorMessage } = vi.hoisted(() => ({
mockImageUpload: vi.fn(),
mockGetImageUploadErrorMessage: vi.fn(() => 'Upload error'),
}))
vi.mock('./utils', () => ({
imageUpload: mockImageUpload,
getImageUploadErrorMessage: mockGetImageUploadErrorMessage,
}))
let fileCounter = 0
const createImageFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
type: TransferMethod.local_file,
_id: `file-${fileCounter++}`,
fileId: '',
progress: 0,
url: 'data:image/png;base64,abc',
...overrides,
})
const createVisionSettings = (overrides: Partial<VisionSettings> = {}): VisionSettings => ({
enabled: true,
number_limits: 5,
detail: Resolution.high,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 10,
...overrides,
})
describe('useImageFiles', () => {
beforeEach(() => {
vi.clearAllMocks()
fileCounter = 0
})
it('should return empty files initially', () => {
const { result } = renderHook(() => useImageFiles())
expect(result.current.files).toEqual([])
})
it('should add a new file via onUpload', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1' })
act(() => {
result.current.onUpload(imageFile)
})
expect(result.current.files).toHaveLength(1)
expect(result.current.files[0]._id).toBe('file-1')
})
it('should update an existing file via onUpload when _id matches', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1', progress: 0 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onUpload({ ...imageFile, progress: 50 })
})
expect(result.current.files).toHaveLength(1)
expect(result.current.files[0].progress).toBe(50)
})
it('should mark a file as deleted via onRemove', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1' })
act(() => {
result.current.onUpload(imageFile)
})
expect(result.current.files).toHaveLength(1)
act(() => {
result.current.onRemove('file-1')
})
// filteredFiles excludes deleted files
expect(result.current.files).toHaveLength(0)
})
it('should not modify files when onRemove is called with non-existent id', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1' })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onRemove('non-existent')
})
expect(result.current.files).toHaveLength(1)
})
it('should set progress to -1 via onImageLinkLoadError', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1', progress: 0 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onImageLinkLoadError('file-1')
})
expect(result.current.files[0].progress).toBe(-1)
})
it('should not modify files when onImageLinkLoadError is called with non-existent id', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1', progress: 0 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onImageLinkLoadError('non-existent')
})
expect(result.current.files[0].progress).toBe(0)
})
it('should set progress to 100 via onImageLinkLoadSuccess', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1', progress: 0 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onImageLinkLoadSuccess('file-1')
})
expect(result.current.files[0].progress).toBe(100)
})
it('should not modify files when onImageLinkLoadSuccess is called with non-existent id', () => {
const { result } = renderHook(() => useImageFiles())
const imageFile = createImageFile({ _id: 'file-1', progress: 50 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onImageLinkLoadSuccess('non-existent')
})
expect(result.current.files[0].progress).toBe(50)
})
it('should clear all files via onClear', () => {
const { result } = renderHook(() => useImageFiles())
act(() => {
result.current.onUpload(createImageFile({ _id: 'file-1' }))
result.current.onUpload(createImageFile({ _id: 'file-2' }))
})
expect(result.current.files).toHaveLength(2)
act(() => {
result.current.onClear()
})
expect(result.current.files).toHaveLength(0)
})
describe('onReUpload', () => {
it('should call imageUpload when re-uploading an existing file', () => {
const { result } = renderHook(() => useImageFiles())
const file = new File(['test'], 'test.png', { type: 'image/png' })
const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onReUpload('file-1')
})
expect(mockImageUpload).toHaveBeenCalledTimes(1)
expect(mockImageUpload).toHaveBeenCalledWith(
expect.objectContaining({
file,
onProgressCallback: expect.any(Function),
onSuccessCallback: expect.any(Function),
onErrorCallback: expect.any(Function),
}),
false,
)
})
it('should not call imageUpload when file id does not exist', () => {
const { result } = renderHook(() => useImageFiles())
act(() => {
result.current.onReUpload('non-existent')
})
expect(mockImageUpload).not.toHaveBeenCalled()
})
it('should update progress via onProgressCallback during re-upload', () => {
const { result } = renderHook(() => useImageFiles())
const file = new File(['test'], 'test.png', { type: 'image/png' })
const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onReUpload('file-1')
})
const uploadCall = mockImageUpload.mock.calls[0][0]
act(() => {
uploadCall.onProgressCallback(50)
})
expect(result.current.files[0].progress).toBe(50)
})
it('should update fileId and progress on success callback during re-upload', () => {
const { result } = renderHook(() => useImageFiles())
const file = new File(['test'], 'test.png', { type: 'image/png' })
const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onReUpload('file-1')
})
const uploadCall = mockImageUpload.mock.calls[0][0]
act(() => {
uploadCall.onSuccessCallback({ id: 'server-file-123' })
})
expect(result.current.files[0].fileId).toBe('server-file-123')
expect(result.current.files[0].progress).toBe(100)
})
it('should set progress to -1 and notify on error callback during re-upload', () => {
const { result } = renderHook(() => useImageFiles())
const file = new File(['test'], 'test.png', { type: 'image/png' })
const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 })
act(() => {
result.current.onUpload(imageFile)
})
act(() => {
result.current.onReUpload('file-1')
})
const uploadCall = mockImageUpload.mock.calls[0][0]
act(() => {
uploadCall.onErrorCallback(new Error('Network error'))
})
expect(result.current.files[0].progress).toBe(-1)
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Upload error' })
})
})
it('should filter out deleted files in returned files', () => {
const { result } = renderHook(() => useImageFiles())
act(() => {
result.current.onUpload(createImageFile({ _id: 'file-1' }))
result.current.onUpload(createImageFile({ _id: 'file-2' }))
result.current.onUpload(createImageFile({ _id: 'file-3' }))
})
act(() => {
result.current.onRemove('file-2')
})
expect(result.current.files).toHaveLength(2)
expect(result.current.files.map(f => f._id)).toEqual(['file-1', 'file-3'])
})
})
describe('useLocalFileUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return disabled status and handleLocalFileUpload function', () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload, limit: 10 }),
)
expect(result.current.disabled).toBe(false)
expect(result.current.handleLocalFileUpload).toBeInstanceOf(Function)
})
it('should not upload when disabled', () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload, disabled: true }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
act(() => {
result.current.handleLocalFileUpload(file)
})
expect(onUpload).not.toHaveBeenCalled()
})
it('should reject files with disallowed extensions', () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload }),
)
const file = new File(['test'], 'test.svg', { type: 'image/svg+xml' })
act(() => {
result.current.handleLocalFileUpload(file)
})
expect(onUpload).not.toHaveBeenCalled()
})
it('should reject files exceeding size limit', () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload, limit: 1 }), // 1MB limit
)
// Create a file larger than 1MB
const largeContent = new Uint8Array(2 * 1024 * 1024)
const file = new File([largeContent], 'test.png', { type: 'image/png' })
act(() => {
result.current.handleLocalFileUpload(file)
})
expect(onUpload).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should read file and call onUpload on successful FileReader load', async () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
act(() => {
result.current.handleLocalFileUpload(file)
})
// Wait for FileReader to complete
await vi.waitFor(() => {
expect(onUpload).toHaveBeenCalled()
})
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({
type: TransferMethod.local_file,
file,
progress: 0,
}),
)
// imageUpload should be called after FileReader load
expect(mockImageUpload).toHaveBeenCalledTimes(1)
})
it('should call onUpload with progress during imageUpload', async () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
act(() => {
result.current.handleLocalFileUpload(file)
})
await vi.waitFor(() => {
expect(mockImageUpload).toHaveBeenCalled()
})
const uploadCall = mockImageUpload.mock.calls[0][0]
act(() => {
uploadCall.onProgressCallback(75)
})
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ progress: 75 }),
)
})
it('should call onUpload with fileId and progress 100 on upload success', async () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
act(() => {
result.current.handleLocalFileUpload(file)
})
await vi.waitFor(() => {
expect(mockImageUpload).toHaveBeenCalled()
})
const uploadCall = mockImageUpload.mock.calls[0][0]
act(() => {
uploadCall.onSuccessCallback({ id: 'uploaded-id' })
})
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 'uploaded-id', progress: 100 }),
)
})
it('should notify error and call onUpload with progress -1 on upload failure', async () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useLocalFileUploader({ onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
act(() => {
result.current.handleLocalFileUpload(file)
})
await vi.waitFor(() => {
expect(mockImageUpload).toHaveBeenCalled()
})
const uploadCall = mockImageUpload.mock.calls[0][0]
act(() => {
uploadCall.onErrorCallback(new Error('fail'))
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ progress: -1 }),
)
})
})
describe('useClipboardUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should be disabled when visionConfig is undefined', () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useClipboardUploader({ files: [], onUpload }),
)
// The hook returns onPaste, and since disabled is true, pasting should not upload
expect(result.current.onPaste).toBeInstanceOf(Function)
})
it('should be disabled when visionConfig.enabled is false', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ enabled: false })
const { result } = renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const mockEvent = {
clipboardData: { files: [file] },
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>
act(() => {
result.current.onPaste(mockEvent)
})
// Paste occurs but the file should NOT be uploaded because disabled
expect(onUpload).not.toHaveBeenCalled()
})
it('should be disabled when local upload is not allowed', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({
transfer_methods: [TransferMethod.remote_url],
})
renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
expect(onUpload).not.toHaveBeenCalled()
})
it('should be disabled when files count reaches number_limits', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ number_limits: 1 })
const files = [createImageFile({ _id: 'file-1' })]
renderHook(() =>
useClipboardUploader({ files, visionConfig: settings, onUpload }),
)
expect(onUpload).not.toHaveBeenCalled()
})
it('should call handleLocalFileUpload when pasting a file', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const mockEvent = {
clipboardData: {
files: [file],
},
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>
act(() => {
result.current.onPaste(mockEvent)
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
it('should not prevent default when pasting text (no file)', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
const mockEvent = {
clipboardData: {
files: [] as File[],
},
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>
act(() => {
result.current.onPaste(mockEvent)
})
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
})
})
describe('useDraggableUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createDragEvent = (files: File[] = []) => ({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
files,
},
} as unknown as DragEvent<HTMLDivElement>)
it('should return drag event handlers and isDragActive state', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
expect(result.current.onDragEnter).toBeInstanceOf(Function)
expect(result.current.onDragOver).toBeInstanceOf(Function)
expect(result.current.onDragLeave).toBeInstanceOf(Function)
expect(result.current.onDrop).toBeInstanceOf(Function)
expect(result.current.isDragActive).toBe(false)
})
it('should set isDragActive to true on dragEnter when not disabled', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragEnter(event)
})
expect(result.current.isDragActive).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should not set isDragActive on dragEnter when disabled', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ enabled: false })
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragEnter(event)
})
expect(result.current.isDragActive).toBe(false)
})
it('should call preventDefault and stopPropagation on dragOver', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragOver(event)
})
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should set isDragActive to false on dragLeave', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
// First activate drag
act(() => {
result.current.onDragEnter(createDragEvent())
})
expect(result.current.isDragActive).toBe(true)
// Then leave
const leaveEvent = createDragEvent()
act(() => {
result.current.onDragLeave(leaveEvent)
})
expect(result.current.isDragActive).toBe(false)
expect(leaveEvent.preventDefault).toHaveBeenCalled()
expect(leaveEvent.stopPropagation).toHaveBeenCalled()
})
it('should set isDragActive to false on drop and upload file', async () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const event = createDragEvent([file])
// Activate drag first
act(() => {
result.current.onDragEnter(createDragEvent())
})
expect(result.current.isDragActive).toBe(true)
act(() => {
result.current.onDrop(event)
})
expect(result.current.isDragActive).toBe(false)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
// Verify the file was actually handed to the upload pipeline
await vi.waitFor(() => {
expect(mockImageUpload).toHaveBeenCalled()
})
})
it('should not upload when dropping with no files', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
files: [] as unknown as FileList,
},
} as unknown as React.DragEvent<HTMLDivElement>
act(() => {
result.current.onDrop(event)
})
// onUpload should not be called directly since no file was dropped
expect(onUpload).not.toHaveBeenCalled()
})
it('should be disabled when files count exceeds number_limits', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ number_limits: 1 })
const files = [createImageFile({ _id: 'file-1' })]
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files, visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragEnter(event)
})
// Should not activate drag when disabled
expect(result.current.isDragActive).toBe(false)
})
})

View File

@@ -0,0 +1,184 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import ImageLinkInput from './image-link-input'
describe('ImageLinkInput', () => {
const defaultProps = {
onUpload: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ImageLinkInput {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render an input with placeholder text', () => {
render(<ImageLinkInput {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder')
expect(input).toHaveAttribute('type', 'text')
})
it('should render a submit button', () => {
render(<ImageLinkInput {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should disable the button when input is empty', () => {
render(<ImageLinkInput {...defaultProps} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should disable the button when disabled prop is true', async () => {
const user = userEvent.setup()
render(<ImageLinkInput {...defaultProps} disabled />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com/image.png')
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable the button when input has text and not disabled', async () => {
const user = userEvent.setup()
render(<ImageLinkInput {...defaultProps} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com/image.png')
expect(screen.getByRole('button')).toBeEnabled()
})
})
describe('User Interactions', () => {
it('should update input value when typing', async () => {
const user = userEvent.setup()
render(<ImageLinkInput {...defaultProps} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com/image.png')
expect(input).toHaveValue('https://example.com/image.png')
})
it('should call onUpload with progress 0 when URL matches http/https/ftp pattern', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
render(<ImageLinkInput onUpload={onUpload} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com/image.png')
await user.click(screen.getByRole('button'))
expect(onUpload).toHaveBeenCalledTimes(1)
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({
type: TransferMethod.remote_url,
url: 'https://example.com/image.png',
progress: 0,
fileId: '',
}),
)
})
it('should call onUpload with progress -1 when URL does not match pattern', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
render(<ImageLinkInput onUpload={onUpload} />)
const input = screen.getByRole('textbox')
await user.type(input, 'not-a-valid-url')
await user.click(screen.getByRole('button'))
expect(onUpload).toHaveBeenCalledTimes(1)
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({
progress: -1,
url: 'not-a-valid-url',
}),
)
})
it('should set progress 0 for http:// URLs', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
render(<ImageLinkInput onUpload={onUpload} />)
await user.type(screen.getByRole('textbox'), 'http://example.com/img.jpg')
await user.click(screen.getByRole('button'))
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ progress: 0 }),
)
})
it('should set progress 0 for ftp:// URLs', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
render(<ImageLinkInput onUpload={onUpload} />)
await user.type(screen.getByRole('textbox'), 'ftp://files.example.com/img.png')
await user.click(screen.getByRole('button'))
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ progress: 0 }),
)
})
it('should not call onUpload when disabled and button is clicked', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
render(<ImageLinkInput onUpload={onUpload} disabled />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com/image.png')
await user.click(screen.getByRole('button'))
// Button is disabled, so click won't fire handleClick
expect(onUpload).not.toHaveBeenCalled()
})
it('should include _id as a timestamp string in the uploaded file', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234567890)
render(<ImageLinkInput onUpload={onUpload} />)
await user.type(screen.getByRole('textbox'), 'https://example.com/img.png')
await user.click(screen.getByRole('button'))
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ _id: '1234567890' }),
)
dateNowSpy.mockRestore()
})
})
describe('Edge Cases', () => {
it('should handle empty string input without errors', () => {
render(<ImageLinkInput {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
expect(screen.getByRole('button')).toBeDisabled()
})
it('should handle URL-like strings without protocol prefix', async () => {
const user = userEvent.setup()
const onUpload = vi.fn()
render(<ImageLinkInput onUpload={onUpload} />)
await user.type(screen.getByRole('textbox'), 'example.com/image.png')
await user.click(screen.getByRole('button'))
expect(onUpload).toHaveBeenCalledWith(
expect.objectContaining({ progress: -1 }),
)
})
})
})

View File

@@ -40,6 +40,7 @@ const ImageLinkInput: FC<ImageLinkInputProps> = ({
value={imageLink}
onChange={e => setImageLink(e.target.value)}
placeholder={t('imageUploader.pasteImageLinkInputPlaceholder', { ns: 'common' }) || ''}
data-testid="image-link-input"
/>
<Button
variant="primary"

View File

@@ -0,0 +1,291 @@
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'))
})
})
})

View File

@@ -1,12 +1,8 @@
/* eslint-disable next/no-img-element */
import type { FC } from 'react'
import type { ImageFile } from '@/types/app'
import {
RiCloseLine,
RiLoader2Line,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import Tooltip from '@/app/components/base/tooltip'
@@ -48,7 +44,7 @@ const ImageList: FC<ImageListProps> = ({
}
return (
<div className="flex flex-wrap">
<div className="flex flex-wrap" data-testid="image-list">
{list.map(item => (
<div
key={item._id}
@@ -61,10 +57,7 @@ const ImageList: FC<ImageListProps> = ({
style={{ left: item.progress > -1 ? `${item.progress}%` : 0 }}
>
{item.progress === -1 && (
<RefreshCcw01
className="h-5 w-5 text-white"
onClick={() => onReUpload?.(item._id)}
/>
<span className="i-custom-vender-line-arrows-refresh-ccw-01 h-5 w-5 text-white" onClick={() => onReUpload?.(item._id)} data-testid="retry-icon" />
)}
</div>
{item.progress > -1 && (
@@ -84,9 +77,10 @@ const ImageList: FC<ImageListProps> = ({
: 'border-transparent bg-black/[0.16]'
}
`}
data-testid="image-error-container"
>
{item.progress > -1 && (
<RiLoader2Line className="h-5 w-5 animate-spin text-white" />
<span className="i-ri-loader-2-line h-5 w-5 animate-spin text-white" data-testid="image-loader" />
)}
{item.progress === -1 && (
<Tooltip
@@ -124,8 +118,9 @@ const ImageList: FC<ImageListProps> = ({
item.progress === -1 ? 'flex' : 'hidden group-hover:flex',
)}
onClick={() => onRemove?.(item._id)}
data-testid="remove-button"
>
<RiCloseLine className="h-3 w-3 text-text-tertiary" />
<span className="i-ri-close-line h-3 w-3 text-text-tertiary" />
</button>
)}
</div>

View File

@@ -0,0 +1,414 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ImagePreview from './image-preview'
type HotkeyHandler = () => void
const mocks = vi.hoisted(() => ({
hotkeys: {} as Record<string, HotkeyHandler>,
notify: vi.fn(),
downloadUrl: vi.fn(),
windowOpen: vi.fn<(...args: unknown[]) => Window | null>(),
clipboardWrite: vi.fn<(items: ClipboardItem[]) => Promise<void>>(),
}))
vi.mock('react-hotkeys-hook', () => ({
useHotkeys: (keys: string, handler: HotkeyHandler) => {
mocks.hotkeys[keys] = handler
},
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (...args: Parameters<typeof mocks.notify>) => mocks.notify(...args),
},
}))
vi.mock('@/utils/download', () => ({
downloadUrl: (...args: Parameters<typeof mocks.downloadUrl>) => mocks.downloadUrl(...args),
}))
const getOverlay = () => screen.getByTestId('image-preview-container') as HTMLDivElement
const getCloseButton = () => screen.getByTestId('image-preview-close-button') as HTMLDivElement
const getCopyButton = () => screen.getByTestId('image-preview-copy-button') as HTMLDivElement
const getZoomOutButton = () => screen.getByTestId('image-preview-zoom-out-button') as HTMLDivElement
const getZoomInButton = () => screen.getByTestId('image-preview-zoom-in-button') as HTMLDivElement
const getDownloadButton = () => screen.getByTestId('image-preview-download-button') as HTMLDivElement
const getOpenInTabButton = () => screen.getByTestId('image-preview-open-in-tab-button') as HTMLDivElement
const base64Image = 'aGVsbG8='
const dataImage = `data:image/png;base64,${base64Image}`
describe('ImagePreview', () => {
const originalClipboardItem = globalThis.ClipboardItem
beforeEach(() => {
vi.clearAllMocks()
mocks.hotkeys = {}
if (!navigator.clipboard) {
Object.defineProperty(globalThis.navigator, 'clipboard', {
value: {
write: vi.fn(),
},
writable: true,
configurable: true,
})
}
const clipboardTarget = navigator.clipboard as { write: (items: ClipboardItem[]) => Promise<void> }
// In some test environments `write` lives on the prototype rather than
// the clipboard instance itself; locate the actual owner so vi.spyOn
// patches the right object.
const writeOwner = Object.prototype.hasOwnProperty.call(clipboardTarget, 'write')
? clipboardTarget
: (Object.getPrototypeOf(clipboardTarget) as { write: (items: ClipboardItem[]) => Promise<void> })
vi.spyOn(writeOwner, 'write').mockImplementation((items: ClipboardItem[]) => {
return mocks.clipboardWrite(items)
})
globalThis.ClipboardItem = class {
constructor(public readonly data: Record<string, Blob>) { }
} as unknown as typeof ClipboardItem
vi.spyOn(window, 'open').mockImplementation((...args: Parameters<Window['open']>) => {
return mocks.windowOpen(...args)
})
})
afterEach(() => {
globalThis.ClipboardItem = originalClipboardItem
vi.restoreAllMocks()
})
describe('Rendering', () => {
it('should render preview in portal with image from url', () => {
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const overlay = getOverlay()
expect(overlay).toBeInTheDocument()
expect(overlay?.parentElement).toBe(document.body)
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', 'https://example.com/image.png')
})
it('should convert plain base64 string into data image src', () => {
render(
<ImagePreview
url={base64Image}
title="Preview Image"
onCancel={vi.fn()}
/>,
)
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', dataImage)
})
})
describe('Hotkeys', () => {
it('should register hotkeys and invoke esc/left/right handlers', () => {
const onCancel = vi.fn()
const onPrev = vi.fn()
const onNext = vi.fn()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={onCancel}
onPrev={onPrev}
onNext={onNext}
/>,
)
expect(mocks.hotkeys.esc).toBeInstanceOf(Function)
expect(mocks.hotkeys.left).toBeInstanceOf(Function)
expect(mocks.hotkeys.right).toBeInstanceOf(Function)
mocks.hotkeys.esc?.()
mocks.hotkeys.left?.()
mocks.hotkeys.right?.()
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onPrev).toHaveBeenCalledTimes(1)
expect(onNext).toHaveBeenCalledTimes(1)
})
})
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={onCancel}
/>,
)
const closeButton = getCloseButton()
await user.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should zoom in and out with wheel interactions', async () => {
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const overlay = getOverlay()
const image = screen.getByRole('img', { name: 'Preview Image' })
act(() => {
overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }))
})
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
})
act(() => {
overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 100 }))
})
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
})
})
it('should update position while dragging when zoomed in and stop dragging on mouseup', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const overlay = getOverlay()
const image = screen.getByRole('img', { name: 'Preview Image' }) as HTMLImageElement
const imageParent = image.parentElement
if (!imageParent)
throw new Error('Image parent element not found')
vi.spyOn(image, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 120,
top: 0,
left: 0,
bottom: 120,
right: 200,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
vi.spyOn(imageParent, 'getBoundingClientRect').mockReturnValue({
width: 100,
height: 100,
top: 0,
left: 0,
bottom: 100,
right: 100,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
const zoomInButton = getZoomInButton()
await user.click(zoomInButton)
act(() => {
overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 }))
overlay.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 40, clientY: 30 }))
})
await waitFor(() => {
expect(image.style.transition).toBe('none')
})
expect(image.style.transform).toContain('translate(')
act(() => {
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
})
await waitFor(() => {
expect(image.style.transition).toContain('transform 0.2s ease-in-out')
})
})
})
describe('Action Buttons', () => {
it('should open valid url in new tab', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
expect(mocks.windowOpen).toHaveBeenCalledWith('https://example.com/image.png', '_blank')
})
it('should open data image by writing to popup window document', async () => {
const user = userEvent.setup()
const write = vi.fn()
mocks.windowOpen.mockReturnValue({
document: {
write,
},
} as unknown as Window)
render(
<ImagePreview
url={dataImage}
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
expect(mocks.windowOpen).toHaveBeenCalledWith()
expect(write).toHaveBeenCalledWith(`<img src="${dataImage}" alt="Preview Image" />`)
})
it('should show error toast when opening unsupported url', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="file:///tmp/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
expect(mocks.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unable to open image: file:///tmp/image.png',
})
})
it('should fall back to download and show info toast when clipboard copy fails', async () => {
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
mocks.clipboardWrite.mockRejectedValue(new Error('copy failed'))
render(
<ImagePreview
url={dataImage}
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const copyButton = getCopyButton()
await user.click(copyButton)
await waitFor(() => {
expect(mocks.downloadUrl).toHaveBeenCalledWith({ url: dataImage, fileName: 'Preview Image.png' })
})
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'info',
}))
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should copy image and show success toast', async () => {
const user = userEvent.setup()
mocks.clipboardWrite.mockResolvedValue()
render(
<ImagePreview
url={dataImage}
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const copyButton = getCopyButton()
await user.click(copyButton)
await waitFor(() => {
expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1)
})
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
it('should call download action for valid url', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const downloadButton = getDownloadButton()
await user.click(downloadButton)
expect(mocks.downloadUrl).toHaveBeenCalledWith({
url: 'https://example.com/image.png',
fileName: 'Preview Image',
target: '_blank',
})
})
it('should show error toast for invalid download url', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="invalid://image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const downloadButton = getDownloadButton()
await user.click(downloadButton)
expect(mocks.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unable to open image: invalid://image.png',
})
})
it('should zoom with dedicated zoom buttons', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const image = screen.getByRole('img', { name: 'Preview Image' })
const zoomInButton = getZoomInButton()
const zoomOutButton = getZoomOutButton()
await user.click(zoomInButton)
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
})
await user.click(zoomOutButton)
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
})
})
})
})

View File

@@ -1,5 +1,4 @@
import type { FC } from 'react'
import { RiAddBoxLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { t } from 'i18next'
import * as React from 'react'
@@ -209,6 +208,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
}}
data-testid="image-preview-image"
/>
<Tooltip popupContent={t('operation.copyImage', { ns: 'common' })}>
<div
@@ -216,8 +216,8 @@ const ImagePreview: FC<ImagePreviewProps> = ({
onClick={imageCopy}
>
{isCopied
? <RiFileCopyLine className="h-4 w-4 text-green-500" />
: <RiFileCopyLine className="h-4 w-4 text-gray-500" />}
? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" />
: <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />}
</div>
</Tooltip>
<Tooltip popupContent={t('operation.zoomOut', { ns: 'common' })}>
@@ -225,7 +225,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
className="absolute right-40 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomOut}
>
<RiZoomOutLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" />
</div>
</Tooltip>
<Tooltip popupContent={t('operation.zoomIn', { ns: 'common' })}>
@@ -233,7 +233,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
className="absolute right-32 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomIn}
>
<RiZoomInLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" />
</div>
</Tooltip>
<Tooltip popupContent={t('operation.download', { ns: 'common' })}>
@@ -241,7 +241,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
className="absolute right-24 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={downloadImage}
>
<RiDownloadCloud2Line className="h-4 w-4 text-gray-500" />
<span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" />
</div>
</Tooltip>
<Tooltip popupContent={t('operation.openInNewTab', { ns: 'common' })}>
@@ -249,7 +249,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
className="absolute right-16 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={openInNewTab}
>
<RiAddBoxLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" />
</div>
</Tooltip>
<Tooltip popupContent={t('operation.cancel', { ns: 'common' })}>

View File

@@ -0,0 +1,223 @@
import type { useLocalFileUploader } from './hooks'
import type { ImageFile, VisionSettings } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import TextGenerationImageUploader from './text-generation-image-uploader'
type LocalUploaderArgs = Parameters<typeof useLocalFileUploader>[0]
const mocks = vi.hoisted(() => ({
files: [] as ImageFile[],
onUpload: vi.fn<(imageFile: ImageFile) => void>(),
onRemove: vi.fn<(imageFileId: string) => void>(),
onImageLinkLoadError: vi.fn<(imageFileId: string) => void>(),
onImageLinkLoadSuccess: vi.fn<(imageFileId: string) => void>(),
onReUpload: vi.fn<(imageFileId: string) => void>(),
handleLocalFileUpload: vi.fn<(file: File) => void>(),
localUploaderArgs: undefined as LocalUploaderArgs | undefined,
}))
vi.mock('./hooks', () => ({
useImageFiles: () => ({
files: mocks.files,
onUpload: mocks.onUpload,
onRemove: mocks.onRemove,
onImageLinkLoadError: mocks.onImageLinkLoadError,
onImageLinkLoadSuccess: mocks.onImageLinkLoadSuccess,
onReUpload: mocks.onReUpload,
}),
useLocalFileUploader: (args: LocalUploaderArgs) => {
mocks.localUploaderArgs = args
return {
handleLocalFileUpload: mocks.handleLocalFileUpload,
}
},
}))
const createSettings = (overrides: Partial<VisionSettings> = {}): VisionSettings => ({
enabled: true,
number_limits: 3,
detail: Resolution.high,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 10,
...overrides,
})
describe('TextGenerationImageUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.files = []
mocks.localUploaderArgs = undefined
})
describe('Rendering', () => {
it('should render local upload action for local_file transfer method', () => {
const onFilesChange = vi.fn()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={onFilesChange} />)
expect(screen.getByText('common.imageUploader.uploadFromComputer')).toBeInTheDocument()
expect(screen.queryByText('common.imageUploader.pasteImageLink')).not.toBeInTheDocument()
})
it('should render URL upload action for remote_url transfer method', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
expect(screen.queryByText('common.imageUploader.uploadFromComputer')).not.toBeInTheDocument()
})
it('should render two-column grid when two transfer methods are enabled', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
const grid = screen.getByTestId('upload-actions')
expect(grid).toHaveClass('grid-cols-2')
})
it('should render single-column grid when one transfer method is enabled', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
const grid = screen.getByTestId('upload-actions')
expect(grid).toHaveClass('grid-cols-1')
})
it('should render no upload action for unsupported transfer method value', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.all],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
expect(screen.queryByText('common.imageUploader.uploadFromComputer')).not.toBeInTheDocument()
expect(screen.queryByText('common.imageUploader.pasteImageLink')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass numeric image size limit to local uploader hook', () => {
const settings = createSettings({
image_file_size_limit: '15',
transfer_methods: [TransferMethod.local_file],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
expect(mocks.localUploaderArgs?.limit).toBe(15)
})
it('should disable local uploader when disabled prop is true', () => {
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(
<TextGenerationImageUploader
settings={settings}
onFilesChange={vi.fn()}
disabled
/>,
)
const fileInput = screen.getByTestId('local-file-input')
expect(fileInput).toBeDisabled()
expect(mocks.localUploaderArgs?.disabled).toBe(true)
})
it('should disable upload actions when file count reaches number limit', async () => {
const user = userEvent.setup()
const settings = createSettings({
number_limits: 1,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
mocks.files = [{
type: TransferMethod.remote_url,
_id: 'file-1',
fileId: 'id-1',
progress: 100,
url: 'https://example.com/image.png',
}]
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
const fileInput = screen.getByTestId('local-file-input')
expect(fileInput).toBeDisabled()
expect(mocks.localUploaderArgs?.disabled).toBe(true)
await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
expect(screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call handleLocalFileUpload when a local file is selected', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.local_file],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
const fileInput = screen.getByTestId('local-file-input')
const file = new File(['content'], 'sample.png', { type: 'image/png' })
await user.upload(fileInput as HTMLInputElement, file)
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(file)
})
it('should open paste link popover and upload remote url', async () => {
const user = userEvent.setup()
const settings = createSettings({
transfer_methods: [TransferMethod.remote_url],
})
render(<TextGenerationImageUploader settings={settings} onFilesChange={vi.fn()} />)
await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
const input = await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')
await user.type(input, 'https://example.com/remote.png')
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
expect(mocks.onUpload).toHaveBeenCalledWith(expect.objectContaining({
type: TransferMethod.remote_url,
url: 'https://example.com/remote.png',
progress: 0,
}))
await waitFor(() => {
expect(screen.queryByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder')).not.toBeInTheDocument()
})
})
})
describe('Files Effect', () => {
it('should call onFilesChange when files value changes', () => {
const onFilesChange = vi.fn()
const settings = createSettings()
const { rerender } = render(<TextGenerationImageUploader settings={settings} onFilesChange={onFilesChange} />)
expect(onFilesChange).toHaveBeenCalledWith([])
const updatedFiles: ImageFile[] = [{
type: TransferMethod.remote_url,
_id: 'new-file',
fileId: '',
progress: 0,
url: 'https://example.com/new.png',
}]
mocks.files = updatedFiles
rerender(<TextGenerationImageUploader settings={settings} onFilesChange={onFilesChange} />)
expect(onFilesChange).toHaveBeenCalledWith(updatedFiles)
})
})
})

View File

@@ -132,7 +132,7 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
/>
</div>
<div className={`grid gap-1 ${settings.transfer_methods.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`}>
<div className={`grid gap-1 ${settings.transfer_methods.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`} data-testid="upload-actions">
{
settings.transfer_methods.map((method) => {
if (method === TransferMethod.local_file)

View File

@@ -0,0 +1,154 @@
import type { ComponentProps } from 'react'
import type { useLocalFileUploader } from './hooks'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
import Uploader from './uploader'
type LocalUploaderArgs = Parameters<typeof useLocalFileUploader>[0]
const mocks = vi.hoisted(() => ({
hookArgs: undefined as LocalUploaderArgs | undefined,
handleLocalFileUpload: vi.fn<(file: File) => void>(),
}))
vi.mock('./hooks', () => ({
useLocalFileUploader: (args: LocalUploaderArgs) => {
mocks.hookArgs = args
return {
handleLocalFileUpload: mocks.handleLocalFileUpload,
}
},
}))
const getInput = () => {
const input = screen.getByTestId('local-file-input')
return input as HTMLInputElement
}
const renderUploader = (props: Partial<ComponentProps<typeof Uploader>> = {}) => {
const onUpload = vi.fn()
const closePopover = vi.fn()
const childRenderer = vi.fn((hovering: boolean) => (
<div data-testid="hover-state">{hovering ? 'hovering' : 'idle'}</div>
))
const result = render(
<Uploader
onUpload={onUpload}
closePopover={closePopover}
limit={3}
disabled={false}
{...props}
>
{childRenderer}
</Uploader>,
)
return {
...result,
onUpload,
closePopover,
childRenderer,
}
}
describe('Uploader', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.hookArgs = undefined
})
describe('Rendering', () => {
it('should render file input and idle child content', () => {
renderUploader()
const input = getInput()
expect(screen.getByTestId('hover-state')).toHaveTextContent('idle')
expect(input).toBeInTheDocument()
})
it('should set accept attribute from allowed file extensions', () => {
renderUploader()
const input = getInput()
const expectedAccept = ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')
expect(input).toHaveAttribute('accept', expectedAccept)
})
it('should pass hook arguments to useLocalFileUploader', () => {
const { onUpload } = renderUploader({ limit: 5, disabled: true })
expect(mocks.hookArgs).toMatchObject({
limit: 5,
disabled: true,
})
expect(mocks.hookArgs?.onUpload).toBe(onUpload)
})
})
describe('User Interactions', () => {
it('should update hovering state on mouse enter and leave', async () => {
const user = userEvent.setup()
renderUploader()
const input = getInput()
expect(screen.getByTestId('hover-state')).toHaveTextContent('idle')
await user.hover(input)
expect(screen.getByTestId('hover-state')).toHaveTextContent('hovering')
await user.unhover(input)
expect(screen.getByTestId('hover-state')).toHaveTextContent('idle')
})
it('should call handleLocalFileUpload and closePopover when file is selected', async () => {
const user = userEvent.setup()
const { closePopover } = renderUploader()
const input = getInput()
const file = new File(['hello'], 'demo.png', { type: 'image/png' })
await user.upload(input, file)
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(file)
expect(closePopover).toHaveBeenCalledTimes(1)
})
it('should reset input value on click', async () => {
const user = userEvent.setup()
renderUploader()
const input = getInput()
const file = new File(['hello'], 'demo.png', { type: 'image/png' })
await user.upload(input, file)
expect(input.files).toHaveLength(1)
await user.click(input)
expect(input.value).toBe('')
})
it('should not upload or close popover when no file is selected', () => {
const { closePopover } = renderUploader()
const input = getInput()
Object.defineProperty(input, 'files', {
value: [] as unknown as FileList,
configurable: true,
})
input.dispatchEvent(new Event('change', { bubbles: true }))
expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled()
expect(closePopover).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('should disable file input when disabled prop is true', () => {
renderUploader({ disabled: true })
const input = getInput()
expect(input).toBeDisabled()
})
})
})

View File

@@ -44,6 +44,7 @@ const Uploader: FC<UploaderProps> = ({
>
{children(hovering)}
<input
data-testid="local-file-input"
className="absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed"
onClick={e => ((e.target as HTMLInputElement).value = '')}
type="file"

View File

@@ -0,0 +1,134 @@
import type { TFunction } from 'i18next'
import { waitFor } from '@testing-library/react'
import { upload } from '@/service/base'
import { getImageUploadErrorMessage, imageUpload } from './utils'
vi.mock('@/service/base', () => ({
upload: vi.fn(),
}))
describe('image-uploader utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getImageUploadErrorMessage', () => {
it('should return backend message when error code is forbidden', () => {
const t = vi.fn() as unknown as TFunction
const result = getImageUploadErrorMessage(
{ response: { code: 'forbidden', message: 'Forbidden by policy' } },
'Default error',
t,
)
expect(result).toBe('Forbidden by policy')
expect(t).not.toHaveBeenCalled()
})
it('should return translated message when error code is file_extension_blocked', () => {
const t = vi.fn(() => 'common.fileUploader.fileExtensionBlocked') as unknown as TFunction
const result = getImageUploadErrorMessage(
{ response: { code: 'file_extension_blocked' } },
'Default error',
t,
)
expect(result).toBe('common.fileUploader.fileExtensionBlocked')
expect(t).toHaveBeenCalledWith('fileUploader.fileExtensionBlocked', { ns: 'common' })
})
it('should return default message when error code is unknown', () => {
const t = vi.fn() as unknown as TFunction
const result = getImageUploadErrorMessage(
{ response: { code: 'unexpected_error' } },
'Default error',
t,
)
expect(result).toBe('Default error')
expect(t).not.toHaveBeenCalled()
})
it('should return default message when error is missing response code', () => {
const t = vi.fn() as unknown as TFunction
const result = getImageUploadErrorMessage(undefined, 'Default error', t)
expect(result).toBe('Default error')
expect(t).not.toHaveBeenCalled()
})
})
describe('imageUpload', () => {
const createCallbacks = () => ({
onProgressCallback: vi.fn<(progress: number) => void>(),
onSuccessCallback: vi.fn<(res: { id: string }) => void>(),
onErrorCallback: vi.fn<(error?: unknown) => void>(),
})
it('should upload file and call success callback', async () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
vi.mocked(upload).mockResolvedValue({ id: 'uploaded-id' })
imageUpload({ file, ...callbacks }, true, '/files/upload')
expect(upload).toHaveBeenCalledTimes(1)
const [options, isPublic, url] = vi.mocked(upload).mock.calls[0]
expect(isPublic).toBe(true)
expect(url).toBe('/files/upload')
expect(options.xhr).toBeInstanceOf(XMLHttpRequest)
expect(options.data).toBeInstanceOf(FormData)
expect((options.data as FormData).get('file')).toBe(file)
await waitFor(() => {
expect(callbacks.onSuccessCallback).toHaveBeenCalledWith({ id: 'uploaded-id' })
})
expect(callbacks.onErrorCallback).not.toHaveBeenCalled()
})
it('should call error callback when upload fails', async () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
const error = new Error('Upload failed')
vi.mocked(upload).mockRejectedValue(error)
imageUpload({ file, ...callbacks })
await waitFor(() => {
expect(callbacks.onErrorCallback).toHaveBeenCalledWith(error)
})
expect(callbacks.onSuccessCallback).not.toHaveBeenCalled()
})
it('should report progress percentage when progress is computable', () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
vi.mocked(upload).mockImplementation((options: { onprogress?: (e: ProgressEvent) => void }) => {
options.onprogress?.({ lengthComputable: true, loaded: 5, total: 8 } as ProgressEvent)
return Promise.resolve({ id: 'uploaded-id' })
})
imageUpload({ file, ...callbacks })
expect(callbacks.onProgressCallback).toHaveBeenCalledWith(62)
})
it('should not report progress when length is not computable', () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
vi.mocked(upload).mockImplementation((options: { onprogress?: (e: ProgressEvent) => void }) => {
options.onprogress?.({ lengthComputable: false, loaded: 5, total: 8 } as ProgressEvent)
return Promise.resolve({ id: 'uploaded-id' })
})
imageUpload({ file, ...callbacks })
expect(callbacks.onProgressCallback).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,117 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import VideoPreview from './video-preview'
const getOverlay = () => screen.getByTestId('video-preview')
const getCloseButton = () => screen.getByTestId('close-button')
describe('VideoPreview', () => {
const defaultProps = {
url: 'https://example.com/video.mp4',
title: 'Test Video',
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<VideoPreview {...defaultProps} />)
expect(screen.getByTitle('Test Video')).toBeInTheDocument()
})
it('should render video element with controls and preload metadata', () => {
render(<VideoPreview {...defaultProps} />)
const video = screen.getByTitle('Test Video')
expect(video.tagName).toBe('VIDEO')
expect(video).toHaveAttribute('controls')
expect(video).toHaveAttribute('preload', 'metadata')
expect((video as HTMLVideoElement).autoplay).toBe(false)
})
it('should render source element with correct src and type', () => {
render(<VideoPreview {...defaultProps} />)
const source = screen.getByTitle('Test Video').querySelector('source')
expect(source).toHaveAttribute('src', 'https://example.com/video.mp4')
expect(source).toHaveAttribute('type', 'video/mp4')
})
it('should render close button', () => {
render(<VideoPreview {...defaultProps} />)
expect(getCloseButton()).toBeInTheDocument()
})
it('should render via portal into document.body', () => {
render(<VideoPreview {...defaultProps} />)
const overlay = getOverlay()
expect(overlay).toBeInTheDocument()
expect(overlay.parentElement).toBe(document.body)
})
})
describe('Props', () => {
it('should set video title from title prop', () => {
render(<VideoPreview {...defaultProps} title="Demo Video" />)
expect(screen.getByTitle('Demo Video')).toBeInTheDocument()
})
it('should set video source from url prop', () => {
render(<VideoPreview {...defaultProps} url="https://example.com/demo.mp4" />)
const source = screen.getByTitle('Test Video').querySelector('source')
expect(source).toHaveAttribute('src', 'https://example.com/demo.mp4')
})
})
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(<VideoPreview {...defaultProps} onCancel={onCancel} />)
const closeButton = getCloseButton()
await user.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should not call onCancel when overlay is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(<VideoPreview {...defaultProps} onCancel={onCancel} />)
const overlay = getOverlay()
await user.click(overlay)
expect(onCancel).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty url', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
render(<VideoPreview {...defaultProps} url="" />)
const source = screen.getByTestId('video-element').querySelector('source')
expect(source).not.toHaveAttribute('src')
consoleErrorSpy.mockRestore()
})
it('should handle empty title', () => {
render(<VideoPreview {...defaultProps} title="" />)
const video = screen.getByTestId('video-element')
expect(video).toBeInTheDocument()
expect(video).toHaveAttribute('title', '')
})
})
})

View File

@@ -1,5 +1,4 @@
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { createPortal } from 'react-dom'
type VideoPreviewProps = {
@@ -13,9 +12,9 @@ const VideoPreview: FC<VideoPreviewProps> = ({
onCancel,
}) => {
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()} data-testid="video-preview">
<div>
<video controls title={title} autoPlay={false} preload="metadata">
<video controls title={title} autoPlay={false} preload="metadata" data-testid="video-element">
<source
type="video/mp4"
src={url}
@@ -27,7 +26,7 @@ const VideoPreview: FC<VideoPreviewProps> = ({
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-button" />
</div>
</div>,
document.body,