test: add tests for some base components (#32415)

This commit is contained in:
Saumya Talwani
2026-02-24 18:38:57 +05:30
committed by GitHub
parent b8fbd7b0f6
commit 0358925d7d
17 changed files with 2129 additions and 13 deletions

View File

@@ -0,0 +1,237 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ImageInput from './ImageInput'
const createObjectURLMock = vi.fn(() => 'blob:mock-url')
const revokeObjectURLMock = vi.fn()
const originalCreateObjectURL = globalThis.URL.createObjectURL
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
const waitForCropperContainer = async () => {
await waitFor(() => {
expect(screen.getByTestId('container')).toBeInTheDocument()
})
}
const loadCropperImage = async () => {
await waitForCropperContainer()
const cropperImage = screen.getByTestId('container').querySelector('img')
if (!cropperImage)
throw new Error('Could not find cropper image')
fireEvent.load(cropperImage)
}
describe('ImageInput', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.URL.createObjectURL = createObjectURLMock
globalThis.URL.revokeObjectURL = revokeObjectURLMock
})
afterEach(() => {
globalThis.URL.createObjectURL = originalCreateObjectURL
globalThis.URL.revokeObjectURL = originalRevokeObjectURL
})
describe('Rendering', () => {
it('should render upload prompt when no image is selected', () => {
render(<ImageInput />)
expect(screen.getByText(/drop.*here/i)).toBeInTheDocument()
expect(screen.getByText(/browse/i)).toBeInTheDocument()
expect(screen.getByText(/supported/i)).toBeInTheDocument()
})
it('should render a hidden file input', () => {
render(<ImageInput />)
const input = screen.getByTestId('image-input')
expect(input).toBeInTheDocument()
expect(input).toHaveClass('hidden')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<ImageInput className="my-custom-class" />)
expect(container.firstChild).toHaveClass('my-custom-class')
})
})
describe('User Interactions', () => {
it('should trigger file input click when browse button is clicked', () => {
render(<ImageInput />)
const fileInput = screen.getByTestId('image-input')
const clickSpy = vi.spyOn(fileInput, 'click')
fireEvent.click(screen.getByText(/browse/i))
expect(clickSpy).toHaveBeenCalled()
})
it('should show Cropper when a static image file is selected', async () => {
render(<ImageInput />)
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
const input = screen.getByTestId('image-input')
fireEvent.change(input, { target: { files: [file] } })
await waitForCropperContainer()
// Upload prompt should be gone
expect(screen.queryByText(/browse/i)).not.toBeInTheDocument()
})
it('should call onImageInput with cropped data when crop completes on static image', async () => {
const onImageInput = vi.fn()
render(<ImageInput onImageInput={onImageInput} />)
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
const input = screen.getByTestId('image-input')
fireEvent.change(input, { target: { files: [file] } })
await loadCropperImage()
await waitFor(() => {
expect(onImageInput).toHaveBeenCalledWith(
true,
'blob:mock-url',
expect.objectContaining({
x: expect.any(Number),
y: expect.any(Number),
width: expect.any(Number),
height: expect.any(Number),
}),
'photo.png',
)
})
})
it('should show img tag and call onImageInput with isCropped=false for animated GIF', async () => {
const onImageInput = vi.fn()
render(<ImageInput onImageInput={onImageInput} />)
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
const file = new File([gifBytes], 'anim.gif', { type: 'image/gif' })
const input = screen.getByTestId('image-input')
fireEvent.change(input, { target: { files: [file] } })
await waitFor(() => {
const img = screen.queryByTestId('animated-image') as HTMLImageElement
expect(img).toBeInTheDocument()
expect(img?.src).toContain('blob:mock-url')
})
// Cropper should NOT be shown
expect(screen.queryByTestId('container')).not.toBeInTheDocument()
expect(onImageInput).toHaveBeenCalledWith(false, file)
})
it('should not crash when file input has no files', () => {
render(<ImageInput />)
const input = screen.getByTestId('image-input')
fireEvent.change(input, { target: { files: null } })
// Should still show upload prompt
expect(screen.getByText(/browse/i)).toBeInTheDocument()
})
it('should reset file input value on click', () => {
render(<ImageInput />)
const input = screen.getByTestId('image-input') as HTMLInputElement
// Simulate previous value
Object.defineProperty(input, 'value', { writable: true, value: 'old-file.png' })
fireEvent.click(input)
expect(input.value).toBe('')
})
})
describe('Drag and Drop', () => {
it('should apply active border class on drag enter', () => {
render(<ImageInput />)
const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement
fireEvent.dragEnter(dropZone)
expect(dropZone).toHaveClass('border-primary-600')
})
it('should remove active border class on drag leave', () => {
render(<ImageInput />)
const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement
fireEvent.dragEnter(dropZone)
expect(dropZone).toHaveClass('border-primary-600')
fireEvent.dragLeave(dropZone)
expect(dropZone).not.toHaveClass('border-primary-600')
})
it('should show image after dropping a file', async () => {
render(<ImageInput />)
const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement
const file = new File(['image-data'], 'dropped.png', { type: 'image/png' })
fireEvent.drop(dropZone, {
dataTransfer: { files: [file] },
})
await waitForCropperContainer()
})
})
describe('Cleanup', () => {
it('should call URL.revokeObjectURL on unmount when an image was set', async () => {
const { unmount } = render(<ImageInput />)
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
const input = screen.getByTestId('image-input')
fireEvent.change(input, { target: { files: [file] } })
await waitForCropperContainer()
unmount()
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url')
})
it('should not call URL.revokeObjectURL on unmount when no image was set', () => {
const { unmount } = render(<ImageInput />)
unmount()
expect(revokeObjectURLMock).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should not crash when onImageInput is not provided', async () => {
render(<ImageInput />)
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
const input = screen.getByTestId('image-input')
// Should not throw
fireEvent.change(input, { target: { files: [file] } })
await loadCropperImage()
await waitFor(() => {
expect(screen.getByTestId('cropper')).toBeInTheDocument()
})
})
it('should accept the correct file extensions', () => {
render(<ImageInput />)
const input = screen.getByTestId('image-input') as HTMLInputElement
expect(input.accept).toContain('.png')
expect(input.accept).toContain('.jpg')
expect(input.accept).toContain('.jpeg')
expect(input.accept).toContain('.webp')
expect(input.accept).toContain('.gif')
})
})
})

View File

@@ -72,7 +72,8 @@ const ImageInput: FC<UploaderProps> = ({
const handleShowImage = () => {
if (isAnimatedImage) {
return (
<img src={inputImage?.url} alt="" />
// eslint-disable-next-line next/no-img-element
<img src={inputImage?.url} alt="" data-testid="animated-image" />
)
}
@@ -107,7 +108,7 @@ const ImageInput: FC<UploaderProps> = ({
<div className="mb-[2px] text-sm font-medium">
<span className="pointer-events-none">
{t('imageInput.dropImageHere', { ns: 'common' })}
&nbsp;
&nbsp;
</span>
<button type="button" className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>{t('imageInput.browse', { ns: 'common' })}</button>
<input
@@ -117,6 +118,7 @@ const ImageInput: FC<UploaderProps> = ({
onClick={e => ((e.target as HTMLInputElement).value = '')}
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
onChange={handleLocalFileInput}
data-testid="image-input"
/>
</div>
<div className="pointer-events-none">{t('imageInput.supportedFormats', { ns: 'common' })}</div>

View File

@@ -0,0 +1,120 @@
import { act, renderHook } from '@testing-library/react'
import { useDraggableUploader } from './hooks'
type MockDragEventOverrides = {
dataTransfer?: { files: File[] }
}
const createDragEvent = (overrides: MockDragEventOverrides = {}): React.DragEvent<HTMLDivElement> => ({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { files: [] as unknown as FileList },
...overrides,
} as unknown as React.DragEvent<HTMLDivElement>)
describe('useDraggableUploader', () => {
let setImageFn: ReturnType<typeof vi.fn<(file: File) => void>>
beforeEach(() => {
vi.clearAllMocks()
setImageFn = vi.fn<(file: File) => void>()
})
describe('Rendering', () => {
it('should return all expected handler functions and isDragActive state', () => {
const { result } = renderHook(() => useDraggableUploader(setImageFn))
expect(result.current.handleDragEnter).toBeInstanceOf(Function)
expect(result.current.handleDragOver).toBeInstanceOf(Function)
expect(result.current.handleDragLeave).toBeInstanceOf(Function)
expect(result.current.handleDrop).toBeInstanceOf(Function)
expect(result.current.isDragActive).toBe(false)
})
})
describe('Drag Events', () => {
it('should set isDragActive to true on drag enter', () => {
const { result } = renderHook(() => useDraggableUploader(setImageFn))
const event = createDragEvent()
act(() => {
result.current.handleDragEnter(event)
})
expect(result.current.isDragActive).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should call preventDefault and stopPropagation on drag over without changing isDragActive', () => {
const { result } = renderHook(() => useDraggableUploader(setImageFn))
const event = createDragEvent()
act(() => {
result.current.handleDragOver(event)
})
expect(result.current.isDragActive).toBe(false)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should set isDragActive to false on drag leave', () => {
const { result } = renderHook(() => useDraggableUploader(setImageFn))
const enterEvent = createDragEvent()
const leaveEvent = createDragEvent()
act(() => {
result.current.handleDragEnter(enterEvent)
})
expect(result.current.isDragActive).toBe(true)
act(() => {
result.current.handleDragLeave(leaveEvent)
})
expect(result.current.isDragActive).toBe(false)
expect(leaveEvent.preventDefault).toHaveBeenCalled()
expect(leaveEvent.stopPropagation).toHaveBeenCalled()
})
})
describe('Drop', () => {
it('should call setImageFn with the dropped file and set isDragActive to false', () => {
const { result } = renderHook(() => useDraggableUploader(setImageFn))
const file = new File(['test'], 'image.png', { type: 'image/png' })
const event = createDragEvent({
dataTransfer: { files: [file] },
})
// First set isDragActive to true
act(() => {
result.current.handleDragEnter(createDragEvent())
})
expect(result.current.isDragActive).toBe(true)
act(() => {
result.current.handleDrop(event)
})
expect(result.current.isDragActive).toBe(false)
expect(setImageFn).toHaveBeenCalledWith(file)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should not call setImageFn when no file is dropped', () => {
const { result } = renderHook(() => useDraggableUploader(setImageFn))
const event = createDragEvent({
dataTransfer: { files: [] },
})
act(() => {
result.current.handleDrop(event)
})
expect(setImageFn).not.toHaveBeenCalled()
expect(result.current.isDragActive).toBe(false)
})
})
})

View File

@@ -0,0 +1,339 @@
import type { Area } from 'react-easy-crop'
import type { ImageFile } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import AppIconPicker from './index'
import 'vitest-canvas-mock'
type LocalFileUploaderOptions = {
disabled?: boolean
limit?: number
onUpload: (imageFile: ImageFile) => void
}
class MockLoadedImage {
width = 320
height = 160
private listeners: Record<string, EventListener[]> = {}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener)
if (!this.listeners[type])
this.listeners[type] = []
this.listeners[type].push(eventListener)
}
setAttribute(_name: string, _value: string) { }
set src(_value: string) {
queueMicrotask(() => {
for (const listener of this.listeners.load ?? [])
listener(new Event('load'))
})
}
get src() {
return ''
}
}
const createImageFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
type: TransferMethod.local_file,
_id: 'test-image-id',
fileId: 'uploaded-image-id',
progress: 100,
url: 'https://example.com/uploaded.png',
...overrides,
})
const createCanvasContextMock = (): CanvasRenderingContext2D =>
({
translate: vi.fn(),
rotate: vi.fn(),
scale: vi.fn(),
drawImage: vi.fn(),
}) as unknown as CanvasRenderingContext2D
const createCanvasElementMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'], { type: 'image/png' })) =>
({
width: 0,
height: 0,
getContext: vi.fn(() => context),
toBlob: vi.fn((callback: BlobCallback) => callback(blob)),
}) as unknown as HTMLCanvasElement
const mocks = vi.hoisted(() => ({
disableUpload: false,
uploadResult: null as ImageFile | null,
onUpload: null as ((imageFile: ImageFile) => void) | null,
handleLocalFileUpload: vi.fn<(file: File) => void>(),
}))
vi.mock('@/config', () => ({
get DISABLE_UPLOAD_IMAGE_AS_ICON() {
return mocks.disableUpload
},
}))
vi.mock('react-easy-crop', () => ({
default: ({ onCropComplete }: { onCropComplete: (_area: Area, croppedAreaPixels: Area) => void }) => (
<div data-testid="mock-cropper">
<button
type="button"
data-testid="trigger-crop"
onClick={() => onCropComplete(
{ x: 0, y: 0, width: 100, height: 100 },
{ x: 0, y: 0, width: 100, height: 100 },
)}
>
Trigger Crop
</button>
</div>
),
}))
vi.mock('../image-uploader/hooks', () => ({
useLocalFileUploader: (options: LocalFileUploaderOptions) => {
mocks.onUpload = options.onUpload
return { handleLocalFileUpload: mocks.handleLocalFileUpload }
},
}))
vi.mock('@/utils/emoji', () => ({
searchEmoji: vi.fn().mockResolvedValue(['grinning', 'sunglasses']),
}))
describe('AppIconPicker', () => {
const originalCreateElement = document.createElement.bind(document)
const originalCreateObjectURL = globalThis.URL.createObjectURL
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
let originalImage: typeof Image
const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => {
vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters<Document['createElement']>) => {
if (args[0] === 'canvas') {
const nextCanvas = canvases.shift()
if (!nextCanvas)
throw new Error('Unexpected canvas creation')
return nextCanvas as ReturnType<Document['createElement']>
}
return originalCreateElement(...args)
})
}
const renderPicker = () => {
const onSelect = vi.fn()
const onClose = vi.fn()
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} />)
return { onSelect, onClose, container }
}
beforeEach(() => {
vi.clearAllMocks()
mocks.disableUpload = false
mocks.uploadResult = createImageFile()
mocks.onUpload = null
mocks.handleLocalFileUpload.mockImplementation(() => {
if (mocks.uploadResult)
mocks.onUpload?.(mocks.uploadResult)
})
originalImage = globalThis.Image
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
globalThis.Image = originalImage
globalThis.URL.createObjectURL = originalCreateObjectURL
globalThis.URL.revokeObjectURL = originalRevokeObjectURL
})
describe('Rendering', () => {
it('should render emoji and image tabs when upload is enabled', async () => {
renderPicker()
expect(await screen.findByText(/emoji/i)).toBeInTheDocument()
expect(screen.getByText(/image/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
expect(screen.getByText(/ok/i)).toBeInTheDocument()
})
it('should hide the image tab when upload is disabled', () => {
mocks.disableUpload = true
renderPicker()
expect(screen.queryByText(/image/i)).not.toBeInTheDocument()
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when cancel is clicked', async () => {
const { onClose } = renderPicker()
await userEvent.click(screen.getByText(/cancel/i))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should switch between emoji and image tabs', async () => {
renderPicker()
await userEvent.click(screen.getByText(/image/i))
expect(screen.getByText(/drop.*here/i)).toBeInTheDocument()
await userEvent.click(screen.getByText(/emoji/i))
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument()
})
it('should call onSelect with emoji data after emoji selection', async () => {
const { onSelect } = renderPicker()
await waitFor(() => {
expect(screen.queryAllByTestId(/emoji-container-/i).length).toBeGreaterThan(0)
})
const firstEmoji = screen.queryAllByTestId(/emoji-container-/i)[0]
if (!firstEmoji)
throw new Error('Could not find emoji option')
await userEvent.click(firstEmoji)
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
type: 'emoji',
icon: expect.any(String),
background: expect.any(String),
}))
})
})
it('should not call onSelect when no emoji has been selected', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/ok/i))
expect(onSelect).not.toHaveBeenCalled()
})
})
describe('Image Upload', () => {
it('should return early when image tab is active and no file has been selected', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
await userEvent.click(screen.getByText(/ok/i))
expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled()
expect(onSelect).not.toHaveBeenCalled()
})
it('should upload cropped static image and emit selected image metadata', async () => {
globalThis.Image = MockLoadedImage as unknown as typeof Image
const sourceCanvas = createCanvasElementMock(createCanvasContextMock())
const croppedBlob = new Blob(['cropped-image'], { type: 'image/png' })
const croppedCanvas = createCanvasElementMock(createCanvasContextMock(), croppedBlob)
mockCanvasCreation([sourceCanvas, croppedCanvas])
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
const input = screen.queryByTestId('image-input')
if (!input)
throw new Error('Could not find image input')
fireEvent.change(input, { target: { files: [new File(['png'], 'avatar.png', { type: 'image/png' })] } })
await waitFor(() => {
expect(screen.getByTestId('mock-cropper')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('trigger-crop'))
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(mocks.handleLocalFileUpload).toHaveBeenCalledTimes(1)
})
const uploadedFile = mocks.handleLocalFileUpload.mock.calls[0][0]
expect(uploadedFile).toBeInstanceOf(File)
expect(uploadedFile.name).toBe('avatar.png')
expect(uploadedFile.type).toBe('image/png')
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith({
type: 'image',
fileId: 'uploaded-image-id',
url: 'https://example.com/uploaded.png',
})
})
})
it('should upload animated image directly without crop', async () => {
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
const gifFile = new File([gifBytes], 'animated.gif', { type: 'image/gif' })
const input = screen.queryByTestId('image-input')
if (!input)
throw new Error('Could not find image input')
fireEvent.change(input, { target: { files: [gifFile] } })
await waitFor(() => {
expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument()
const preview = screen.queryByTestId('animated-image')
expect(preview).toBeInTheDocument()
expect(preview?.getAttribute('src')).toContain('blob:mock-url')
})
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile)
})
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith({
type: 'image',
fileId: 'uploaded-image-id',
url: 'https://example.com/uploaded.png',
})
})
})
it('should not call onSelect when upload callback returns image without fileId', async () => {
mocks.uploadResult = createImageFile({ fileId: '' })
const { onSelect } = renderPicker()
await userEvent.click(screen.getByText(/image/i))
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
const gifFile = new File([gifBytes], 'no-file-id.gif', { type: 'image/gif' })
const input = screen.queryByTestId('image-input')
if (!input)
throw new Error('Could not find image input')
fireEvent.change(input, { target: { files: [gifFile] } })
await waitFor(() => {
expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument()
})
await userEvent.click(screen.getByText(/ok/i))
await waitFor(() => {
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile)
})
expect(onSelect).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,364 @@
import getCroppedImg, { checkIsAnimatedImage, createImage, getMimeType, getRadianAngle, rotateSize } from './utils'
type ImageLoadEventType = 'load' | 'error'
class MockImageElement {
static nextEvent: ImageLoadEventType = 'load'
width = 320
height = 160
crossOriginValue = ''
srcValue = ''
private listeners: Record<string, EventListener[]> = {}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener)
if (!this.listeners[type])
this.listeners[type] = []
this.listeners[type].push(eventListener)
}
setAttribute(name: string, value: string) {
if (name === 'crossOrigin')
this.crossOriginValue = value
}
set src(value: string) {
this.srcValue = value
queueMicrotask(() => {
const event = new Event(MockImageElement.nextEvent)
for (const listener of this.listeners[MockImageElement.nextEvent] ?? [])
listener(event)
})
}
get src() {
return this.srcValue
}
}
type CanvasMock = {
element: HTMLCanvasElement
getContextMock: ReturnType<typeof vi.fn>
toBlobMock: ReturnType<typeof vi.fn>
}
const createCanvasMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'])): CanvasMock => {
const getContextMock = vi.fn(() => context)
const toBlobMock = vi.fn((callback: BlobCallback) => callback(blob))
return {
element: {
width: 0,
height: 0,
getContext: getContextMock,
toBlob: toBlobMock,
} as unknown as HTMLCanvasElement,
getContextMock,
toBlobMock,
}
}
const createCanvasContextMock = (): CanvasRenderingContext2D =>
({
translate: vi.fn(),
rotate: vi.fn(),
scale: vi.fn(),
drawImage: vi.fn(),
}) as unknown as CanvasRenderingContext2D
describe('utils', () => {
const originalCreateElement = document.createElement.bind(document)
let originalImage: typeof Image
beforeEach(() => {
vi.clearAllMocks()
originalImage = globalThis.Image
MockImageElement.nextEvent = 'load'
})
afterEach(() => {
globalThis.Image = originalImage
vi.restoreAllMocks()
})
const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => {
vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters<Document['createElement']>) => {
if (args[0] === 'canvas') {
const nextCanvas = canvases.shift()
if (!nextCanvas)
throw new Error('Unexpected canvas creation')
return nextCanvas as ReturnType<Document['createElement']>
}
return originalCreateElement(...args)
})
}
describe('createImage', () => {
it('should resolve image when load event fires', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
const image = await createImage('https://example.com/image.png')
const mockImage = image as unknown as MockImageElement
expect(mockImage.crossOriginValue).toBe('anonymous')
expect(mockImage.src).toBe('https://example.com/image.png')
})
it('should reject when error event fires', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
MockImageElement.nextEvent = 'error'
await expect(createImage('https://example.com/broken.png')).rejects.toBeInstanceOf(Event)
})
})
describe('getMimeType', () => {
it('should return image/png for .png files', () => {
expect(getMimeType('photo.png')).toBe('image/png')
})
it('should return image/jpeg for .jpg files', () => {
expect(getMimeType('photo.jpg')).toBe('image/jpeg')
})
it('should return image/jpeg for .jpeg files', () => {
expect(getMimeType('photo.jpeg')).toBe('image/jpeg')
})
it('should return image/gif for .gif files', () => {
expect(getMimeType('animation.gif')).toBe('image/gif')
})
it('should return image/webp for .webp files', () => {
expect(getMimeType('photo.webp')).toBe('image/webp')
})
it('should return image/jpeg as default for unknown extensions', () => {
expect(getMimeType('file.bmp')).toBe('image/jpeg')
})
it('should return image/jpeg for files with no extension', () => {
expect(getMimeType('file')).toBe('image/jpeg')
})
it('should handle uppercase extensions via toLowerCase', () => {
expect(getMimeType('photo.PNG')).toBe('image/png')
})
})
describe('getRadianAngle', () => {
it('should return 0 for 0 degrees', () => {
expect(getRadianAngle(0)).toBe(0)
})
it('should return PI/2 for 90 degrees', () => {
expect(getRadianAngle(90)).toBeCloseTo(Math.PI / 2)
})
it('should return PI for 180 degrees', () => {
expect(getRadianAngle(180)).toBeCloseTo(Math.PI)
})
it('should return 2*PI for 360 degrees', () => {
expect(getRadianAngle(360)).toBeCloseTo(2 * Math.PI)
})
it('should handle negative angles', () => {
expect(getRadianAngle(-90)).toBeCloseTo(-Math.PI / 2)
})
})
describe('rotateSize', () => {
it('should return same dimensions for 0 degree rotation', () => {
const result = rotateSize(100, 200, 0)
expect(result.width).toBeCloseTo(100)
expect(result.height).toBeCloseTo(200)
})
it('should swap dimensions for 90 degree rotation', () => {
const result = rotateSize(100, 200, 90)
expect(result.width).toBeCloseTo(200)
expect(result.height).toBeCloseTo(100)
})
it('should return same dimensions for 180 degree rotation', () => {
const result = rotateSize(100, 200, 180)
expect(result.width).toBeCloseTo(100)
expect(result.height).toBeCloseTo(200)
})
it('should handle square dimensions', () => {
const result = rotateSize(100, 100, 45)
// 45° rotation of a square produces a larger bounding box
const expected = Math.abs(Math.cos(Math.PI / 4) * 100) + Math.abs(Math.sin(Math.PI / 4) * 100)
expect(result.width).toBeCloseTo(expected)
expect(result.height).toBeCloseTo(expected)
})
})
describe('getCroppedImg', () => {
it('should return a blob when canvas operations succeed', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
const sourceContext = createCanvasContextMock()
const croppedContext = createCanvasContextMock()
const sourceCanvas = createCanvasMock(sourceContext)
const expectedBlob = new Blob(['cropped'], { type: 'image/webp' })
const croppedCanvas = createCanvasMock(croppedContext, expectedBlob)
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
const result = await getCroppedImg(
'https://example.com/image.webp',
{ x: 10, y: 20, width: 50, height: 40 },
'avatar.webp',
90,
{ horizontal: true, vertical: false },
)
expect(result).toBe(expectedBlob)
expect(croppedCanvas.toBlobMock).toHaveBeenCalledWith(expect.any(Function), 'image/webp')
expect(sourceContext.translate).toHaveBeenCalled()
expect(sourceContext.rotate).toHaveBeenCalled()
expect(sourceContext.scale).toHaveBeenCalledWith(-1, 1)
expect(croppedContext.drawImage).toHaveBeenCalled()
})
it('should apply vertical flip when vertical option is true', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
const sourceContext = createCanvasContextMock()
const croppedContext = createCanvasContextMock()
const sourceCanvas = createCanvasMock(sourceContext)
const croppedCanvas = createCanvasMock(croppedContext)
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
await getCroppedImg(
'https://example.com/image.png',
{ x: 0, y: 0, width: 20, height: 20 },
'avatar.png',
0,
{ horizontal: false, vertical: true },
)
expect(sourceContext.scale).toHaveBeenCalledWith(1, -1)
})
it('should throw when source canvas context is unavailable', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
const sourceCanvas = createCanvasMock(null)
mockCanvasCreation([sourceCanvas.element])
await expect(
getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'),
).rejects.toThrow('Could not create a canvas context')
})
it('should throw when cropped canvas context is unavailable', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
const sourceCanvas = createCanvasMock(createCanvasContextMock())
const croppedCanvas = createCanvasMock(null)
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
await expect(
getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'),
).rejects.toThrow('Could not create a canvas context')
})
it('should reject when blob creation fails', async () => {
globalThis.Image = MockImageElement as unknown as typeof Image
const sourceCanvas = createCanvasMock(createCanvasContextMock())
const croppedCanvas = createCanvasMock(createCanvasContextMock(), null)
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
await expect(
getCroppedImg('https://example.com/image.jpg', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.jpg'),
).rejects.toThrow('Could not create a blob')
})
})
describe('checkIsAnimatedImage', () => {
let originalFileReader: typeof FileReader
beforeEach(() => {
originalFileReader = globalThis.FileReader
})
afterEach(() => {
globalThis.FileReader = originalFileReader
})
it('should return true for .gif files', async () => {
const gifFile = new File([new Uint8Array([0x47, 0x49, 0x46])], 'animation.gif', { type: 'image/gif' })
const result = await checkIsAnimatedImage(gifFile)
expect(result).toBe(true)
})
it('should return false for non-gif, non-webp files', async () => {
const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'image.png', { type: 'image/png' })
const result = await checkIsAnimatedImage(pngFile)
expect(result).toBe(false)
})
it('should return true for animated WebP files with ANIM chunk', async () => {
// Build a minimal WebP header with ANIM chunk
// RIFF....WEBP....ANIM
const bytes = new Uint8Array(20)
// RIFF signature
bytes[0] = 0x52 // R
bytes[1] = 0x49 // I
bytes[2] = 0x46 // F
bytes[3] = 0x46 // F
// WEBP signature
bytes[8] = 0x57 // W
bytes[9] = 0x45 // E
bytes[10] = 0x42 // B
bytes[11] = 0x50 // P
// ANIM chunk at offset 12
bytes[12] = 0x41 // A
bytes[13] = 0x4E // N
bytes[14] = 0x49 // I
bytes[15] = 0x4D // M
const webpFile = new File([bytes], 'animated.webp', { type: 'image/webp' })
const result = await checkIsAnimatedImage(webpFile)
expect(result).toBe(true)
})
it('should return false for static WebP files without ANIM chunk', async () => {
const bytes = new Uint8Array(20)
// RIFF signature
bytes[0] = 0x52
bytes[1] = 0x49
bytes[2] = 0x46
bytes[3] = 0x46
// WEBP signature
bytes[8] = 0x57
bytes[9] = 0x45
bytes[10] = 0x42
bytes[11] = 0x50
// No ANIM chunk
const webpFile = new File([bytes], 'static.webp', { type: 'image/webp' })
const result = await checkIsAnimatedImage(webpFile)
expect(result).toBe(false)
})
it('should reject when FileReader encounters an error', async () => {
const file = new File([], 'test.png', { type: 'image/png' })
globalThis.FileReader = class {
onerror: ((error: ProgressEvent<FileReader>) => void) | null = null
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
readAsArrayBuffer(_blob: Blob) {
const errorEvent = new ProgressEvent('error') as ProgressEvent<FileReader>
setTimeout(() => {
this.onerror?.(errorEvent)
}, 0)
}
} as unknown as typeof FileReader
await expect(checkIsAnimatedImage(file)).rejects.toBeInstanceOf(ProgressEvent)
})
})
})

View File

@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react'
import GridMask from './index'
import Style from './style.module.css'
function renderGridMask(props: Partial<React.ComponentProps<typeof GridMask>> = {}, children: React.ReactNode = <span>Child</span>) {
const { container } = render(<GridMask {...props}>{children}</GridMask>)
const wrapper = container.firstElementChild as HTMLElement
const canvasLayer = wrapper.children[0] as HTMLElement
const gradientLayer = wrapper.children[1] as HTMLElement
const contentLayer = wrapper.children[2] as HTMLElement
return { container, wrapper, canvasLayer, gradientLayer, contentLayer }
}
describe('GridMask', () => {
describe('Rendering', () => {
it('should render children in the content layer', () => {
renderGridMask({}, <button>Run</button>)
expect(screen.getByRole('button', { name: 'Run' })).toBeInTheDocument()
})
it('should render correctly without optional className props', () => {
const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask({}, <span>Plain child</span>)
expect(wrapper).toHaveClass('bg-saas-background')
expect(canvasLayer).toHaveClass('absolute')
expect(gradientLayer).toHaveClass('absolute')
expect(contentLayer).toHaveTextContent('Plain child')
})
it('should render wrapper, canvas, gradient and content layers in order', () => {
const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask({}, <span>Content</span>)
expect(wrapper).toBeInTheDocument()
expect(wrapper.children).toHaveLength(3)
expect(canvasLayer).toHaveClass('z-0')
expect(gradientLayer).toHaveClass('z-[1]')
expect(contentLayer).toHaveClass('z-[2]')
expect(contentLayer).toHaveTextContent('Content')
})
})
describe('Props', () => {
it('should apply wrapperClassName to wrapper element', () => {
const { wrapper } = renderGridMask({ wrapperClassName: 'custom-wrapper' }, <span>Child</span>)
expect(wrapper).toHaveClass('custom-wrapper')
expect(wrapper).toHaveClass('relative')
})
it('should apply canvasClassName and grid background class to canvas layer', () => {
const { canvasLayer } = renderGridMask({ canvasClassName: 'custom-canvas' }, <span>Child</span>)
expect(canvasLayer).toHaveClass('custom-canvas')
expect(canvasLayer).toHaveClass(Style.gridBg)
})
it('should apply gradientClassName to gradient layer', () => {
const { gradientLayer } = renderGridMask({ gradientClassName: 'custom-gradient' }, <span>Child</span>)
expect(gradientLayer).toHaveClass('custom-gradient')
expect(gradientLayer).toHaveClass('bg-grid-mask-background')
})
})
})

View File

@@ -0,0 +1,144 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ImageGallery, { ImageGalleryTest } from '.'
const getImages = (container: HTMLElement) => container.querySelectorAll('img')
describe('ImageGallery', () => {
describe('Rendering', () => {
it('should render a single image', () => {
const { container } = render(<ImageGallery srcs={['https://example.com/img1.png']} />)
const imgs = getImages(container)
expect(imgs).toHaveLength(1)
expect(imgs[0]).toHaveAttribute('src', 'https://example.com/img1.png')
})
it('should render multiple images', () => {
const srcs = ['https://example.com/1.png', 'https://example.com/2.png', 'https://example.com/3.png']
const { container } = render(<ImageGallery srcs={srcs} />)
expect(getImages(container)).toHaveLength(3)
})
it('should skip falsy src values', () => {
const srcs = ['https://example.com/1.png', '', 'https://example.com/3.png']
const { container } = render(<ImageGallery srcs={srcs} />)
expect(getImages(container)).toHaveLength(2)
})
it('should render no images when srcs is empty', () => {
const { container } = render(<ImageGallery srcs={[]} />)
expect(getImages(container)).toHaveLength(0)
})
it('should not render ImagePreview initially', () => {
render(<ImageGallery srcs={['https://example.com/img.png']} />)
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()
})
})
describe('Width Styles', () => {
it('should apply maxWidth 100% for a single image', () => {
const { container } = render(<ImageGallery srcs={['https://example.com/1.png']} />)
const img = getImages(container)[0]
expect(img.style.maxWidth).toBe('100%')
})
it('should apply calc(50% - 4px) width for 2 images', () => {
const { container } = render(<ImageGallery srcs={['https://example.com/1.png', 'https://example.com/2.png']} />)
getImages(container).forEach(img => expect(img.style.width).toBe('calc(50% - 4px)'))
})
it('should apply calc(50% - 4px) width for 4 images', () => {
const srcs = Array.from({ length: 4 }, (_, i) => `https://example.com/${i}.png`)
const { container } = render(<ImageGallery srcs={srcs} />)
getImages(container).forEach(img => expect(img.style.width).toBe('calc(50% - 4px)'))
})
it('should apply calc(33.3333% - 5.3333px) width for 3 images', () => {
const srcs = Array.from({ length: 3 }, (_, i) => `https://example.com/${i}.png`)
const { container } = render(<ImageGallery srcs={srcs} />)
getImages(container).forEach(img => expect(img.style.width).toBe('calc(33.3333% - 5.3333px)'))
})
it('should apply calc(33.3333% - 5.3333px) width for 5 images', () => {
const srcs = Array.from({ length: 5 }, (_, i) => `https://example.com/${i}.png`)
const { container } = render(<ImageGallery srcs={srcs} />)
getImages(container).forEach(img => expect(img.style.width).toBe('calc(33.3333% - 5.3333px)'))
})
it('should apply calc(33.3333% - 5.3333px) width for 6 images', () => {
const srcs = Array.from({ length: 6 }, (_, i) => `https://example.com/${i}.png`)
const { container } = render(<ImageGallery srcs={srcs} />)
getImages(container).forEach(img => expect(img.style.width).toBe('calc(33.3333% - 5.3333px)'))
})
})
describe('Image Preview', () => {
it('should show ImagePreview when an image is clicked', async () => {
const user = userEvent.setup()
const { container } = render(<ImageGallery srcs={['https://example.com/img1.png']} />)
await user.click(getImages(container)[0])
const previewContainer = screen.queryByTestId('image-preview-container')
expect(previewContainer).toBeInTheDocument()
expect(previewContainer?.querySelector('img')).toHaveAttribute('src', 'https://example.com/img1.png')
})
it('should show preview for the specific clicked image', async () => {
const user = userEvent.setup()
const srcs = ['https://example.com/1.png', 'https://example.com/2.png']
const { container } = render(<ImageGallery srcs={srcs} />)
await user.click(getImages(container)[1])
const previewContainer = screen.queryByTestId('image-preview-container')
expect(previewContainer?.querySelector('img')).toHaveAttribute('src', 'https://example.com/2.png')
})
it('should hide ImagePreview when Escape is pressed', async () => {
const user = userEvent.setup()
const { container } = render(<ImageGallery srcs={['https://example.com/img1.png']} />)
await user.click(getImages(container)[0])
expect(screen.queryByTestId('image-preview-container')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()
})
})
})
describe('Error Handling', () => {
it('should remove image element on error', () => {
const { container } = render(<ImageGallery srcs={['https://example.com/broken.png']} />)
const img = getImages(container)[0]
fireEvent.error(img)
expect(getImages(container)).toHaveLength(0)
})
})
})
describe('ImageGalleryTest', () => {
it('should render multiple ImageGallery instances', () => {
const { container } = render(<ImageGalleryTest />)
const imgs = getImages(container)
// 6 images renders galleries with 1+2+3+4+5+6 = 21 images total
expect(imgs.length).toBe(21)
})
})

View File

@@ -196,6 +196,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
onMouseUp={handleMouseUp}
style={{ cursor: scale > 1 ? 'move' : 'default' }}
tabIndex={-1}
data-testid="image-preview-container"
>
{ }
{/* eslint-disable-next-line next/no-img-element */}

View File

@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ParamItem from '.'
describe('ParamItem Slider onChange', () => {
const defaultProps = {
id: 'test_param',
name: 'Test Param',
enable: true,
onChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should divide slider value by 100 when max < 5', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={0.5} min={0} max={1} />)
const slider = screen.getByRole('slider')
await user.click(slider)
await user.keyboard('{ArrowRight}')
// max=1 < 5, so slider value change (50->51) becomes 0.51
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.51)
})
it('should not divide slider value when max >= 5', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
const slider = screen.getByRole('slider')
await user.click(slider)
await user.keyboard('{ArrowRight}')
// max=10 >= 5, so value remains raw (5->6)
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 6)
})
})

View File

@@ -0,0 +1,179 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import ParamItem from '.'
describe('ParamItem', () => {
const defaultProps = {
id: 'test_param',
name: 'Test Param',
value: 0.5,
enable: true,
max: 1,
onChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the parameter name', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.getByText('Test Param')).toBeInTheDocument()
})
it('should render a tooltip trigger by default', () => {
const { container } = render(<ParamItem {...defaultProps} tip="Some tip text" />)
// Tooltip trigger icon should be rendered (the data-state div)
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
it('should not render tooltip trigger when noTooltip is true', () => {
const { container } = render(<ParamItem {...defaultProps} noTooltip tip="Hidden tip" />)
// No tooltip trigger icon should be rendered
expect(container.querySelector('[data-state]')).not.toBeInTheDocument()
})
it('should render a switch when hasSwitch is true', () => {
render(<ParamItem {...defaultProps} hasSwitch />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should not render a switch by default', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('should render InputNumber and Slider', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<ParamItem {...defaultProps} className="my-custom-class" />)
expect(container.firstChild).toHaveClass('my-custom-class')
})
it('should disable InputNumber when enable is false', () => {
render(<ParamItem {...defaultProps} enable={false} />)
expect(screen.getByRole('spinbutton')).toBeDisabled()
})
it('should disable Slider when enable is false', () => {
render(<ParamItem {...defaultProps} enable={false} />)
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
it('should set switch value based on enable prop', () => {
render(<ParamItem {...defaultProps} hasSwitch enable={true} />)
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
})
describe('User Interactions', () => {
it('should call onChange with id and value when InputNumber changes', async () => {
const user = userEvent.setup()
const StatefulParamItem = () => {
const [value, setValue] = useState(defaultProps.value)
return (
<ParamItem
{...defaultProps}
value={value}
onChange={(key, nextValue) => {
defaultProps.onChange(key, nextValue)
setValue(nextValue)
}}
/>
)
}
render(<StatefulParamItem />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '0.8')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.8)
})
it('should pass scaled value to slider when max < 5', () => {
render(<ParamItem {...defaultProps} value={0.5} />)
const slider = screen.getByRole('slider')
// When max < 5, slider value = value * 100 = 50
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should pass raw value to slider when max >= 5', () => {
render(<ParamItem {...defaultProps} value={5} max={10} />)
const slider = screen.getByRole('slider')
// When max >= 5, slider value = value = 5
expect(slider).toHaveAttribute('aria-valuenow', '5')
})
it('should call onSwitchChange with id and value when switch is toggled', async () => {
const user = userEvent.setup()
const onSwitchChange = vi.fn()
render(<ParamItem {...defaultProps} hasSwitch onSwitchChange={onSwitchChange} />)
await user.click(screen.getByRole('switch'))
expect(onSwitchChange).toHaveBeenCalledWith('test_param', expect.any(Boolean))
})
it('should call onChange with id when increment button is clicked', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={0.5} step={0.1} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
// step=0.1, so 0.5 + 0.1 = 0.6, clamped to [0,1] → 0.6
expect(defaultProps.onChange).toHaveBeenCalledWith('test_param', 0.6)
})
})
describe('Edge Cases', () => {
it('should correctly scale slider value when max < 5', () => {
render(<ParamItem {...defaultProps} value={0.5} min={0} />)
// Slider should get value * 100 = 50, min * 100 = 0, max * 100 = 100
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '100')
})
it('should not scale slider value when max >= 5', () => {
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '10')
})
it('should use default step of 0.1 and min of 0 when not provided', () => {
render(<ParamItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
// Component renders without error with default step/min
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(input).toHaveAttribute('step', '0.1')
expect(input).toHaveAttribute('min', '0')
})
})
})

View File

@@ -0,0 +1,145 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import ScoreThresholdItem from './score-threshold-item'
describe('ScoreThresholdItem', () => {
const defaultProps = {
value: 0.7,
enable: true,
onChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByText('appDebug.datasetConfig.score_threshold')).toBeInTheDocument()
})
it('should render tooltip trigger', () => {
const { container } = render(<ScoreThresholdItem {...defaultProps} />)
// Tooltip trigger icon should be rendered
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
it('should render InputNumber and Slider', () => {
render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<ScoreThresholdItem {...defaultProps} className="custom-cls" />)
expect(container.firstChild).toHaveClass('custom-cls')
})
it('should render switch when hasSwitch is true', () => {
render(<ScoreThresholdItem {...defaultProps} hasSwitch />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should forward onSwitchChange to ParamItem', async () => {
const onSwitchChange = vi.fn()
render(<ScoreThresholdItem {...defaultProps} hasSwitch onSwitchChange={onSwitchChange} />)
// Verify the switch rendered (onSwitchChange forwarded internally)
expect(screen.getByRole('switch')).toBeInTheDocument()
await userEvent.click(screen.getByRole('switch'))
expect(onSwitchChange).toHaveBeenCalledTimes(1)
})
it('should disable controls when enable is false', () => {
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
expect(screen.getByRole('spinbutton')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
describe('Value Clamping', () => {
it('should clamp values to minimum of 0', () => {
render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '0')
})
it('should clamp values to maximum of 1', () => {
render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('max', '1')
})
it('should use step of 0.01', () => {
render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('step', '0.01')
})
it('should call onChange with rounded value when input changes', async () => {
const user = userEvent.setup()
const StatefulScoreThresholdItem = () => {
const [value, setValue] = useState(defaultProps.value)
return (
<ScoreThresholdItem
{...defaultProps}
value={value}
onChange={(key, nextValue) => {
defaultProps.onChange(key, nextValue)
setValue(nextValue)
}}
/>
)
}
render(<StatefulScoreThresholdItem />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '0.55')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('score_threshold', 0.55)
})
it('should call onChange with clamped value via increment button', async () => {
const user = userEvent.setup()
render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
// step=0.01, so 0.5 + 0.01 = 0.51, clamped to [0,1] → 0.51
expect(defaultProps.onChange).toHaveBeenCalledWith('score_threshold', 0.51)
})
it('should call onChange with clamped value via decrement button', async () => {
const user = userEvent.setup()
render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(defaultProps.onChange).toHaveBeenCalledWith('score_threshold', 0.49)
})
it('should clamp to max=1 when value exceeds maximum', () => {
render(<ScoreThresholdItem {...defaultProps} value={1.5} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(1)
})
})
})

View File

@@ -35,6 +35,11 @@ const ScoreThresholdItem: FC<Props> = ({
notOutRangeValue = Math.min(VALUE_LIMIT.max, notOutRangeValue)
onChange(key, notOutRangeValue)
}
const safeValue = Math.min(
VALUE_LIMIT.max,
Math.max(VALUE_LIMIT.min, Number.parseFloat(value.toFixed(2))),
)
return (
<ParamItem
className={className}
@@ -42,7 +47,7 @@ const ScoreThresholdItem: FC<Props> = ({
name={t('datasetConfig.score_threshold', { ns: 'appDebug' })}
tip={t('datasetConfig.score_thresholdTip', { ns: 'appDebug' }) as string}
{...VALUE_LIMIT}
value={value}
value={safeValue}
enable={enable}
onChange={handleParamChange}
hasSwitch={hasSwitch}

View File

@@ -0,0 +1,130 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TopKItem from './top-k-item'
vi.mock('@/env', () => ({
env: {
NEXT_PUBLIC_TOP_K_MAX_VALUE: 10,
},
}))
describe('TopKItem', () => {
const defaultProps = {
value: 2,
enable: true,
onChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.getByText('appDebug.datasetConfig.top_k')).toBeInTheDocument()
})
it('should render tooltip trigger', () => {
const { container } = render(<TopKItem {...defaultProps} />)
// Tooltip trigger icon should be rendered
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
it('should render InputNumber and Slider', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<TopKItem {...defaultProps} className="custom-cls" />)
expect(container.firstChild).toHaveClass('custom-cls')
})
it('should disable controls when enable is false', () => {
render(<TopKItem {...defaultProps} enable={false} />)
expect(screen.getByRole('spinbutton')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
describe('Value Limits', () => {
it('should use step of 1', () => {
render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('step', '1')
})
it('should use minimum of 1', () => {
render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
it('should use maximum from env (10)', () => {
render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('max', '10')
})
it('should render slider with max >= 5 so no scaling is applied', () => {
render(<TopKItem {...defaultProps} />)
const slider = screen.getByRole('slider')
// max=10 >= 5 so slider shows raw values
expect(slider).toHaveAttribute('aria-valuemax', '10')
})
it('should not render a switch (no hasSwitch prop)', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with clamped integer value via increment button', async () => {
const user = userEvent.setup()
render(<TopKItem {...defaultProps} value={5} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
// step=1, so 5 + 1 = 6, clamped to [1,10] → 6
expect(defaultProps.onChange).toHaveBeenCalledWith('top_k', 6)
})
it('should call onChange with clamped integer value via decrement button', async () => {
const user = userEvent.setup()
render(<TopKItem {...defaultProps} value={5} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
// step=1, so 5 - 1 = 4, clamped to [1,10] → 4
expect(defaultProps.onChange).toHaveBeenCalledWith('top_k', 4)
})
it('should call onChange with integer value when slider changes', async () => {
const user = userEvent.setup()
render(<TopKItem {...defaultProps} value={2} />)
const slider = screen.getByRole('slider')
await user.click(slider)
await user.keyboard('{ArrowRight}')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('top_k', 3)
})
})
})

View File

@@ -0,0 +1,187 @@
import type { ComponentProps } from 'react'
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagInput from './index'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
type TagInputProps = ComponentProps<typeof TagInput>
const renderTagInput = (props: Partial<TagInputProps> = {}) => {
const onChange = vi.fn<(items: string[]) => void>()
const items = props.items ?? []
render(<TagInput items={items} onChange={onChange} {...props} />)
return { onChange }
}
describe('TagInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render existing tags and default placeholder', () => {
renderTagInput({ items: ['alpha', 'beta'] })
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.getByText('beta')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetDocuments.segment.addKeyWord')).toBeInTheDocument()
})
it('should render special mode placeholder when confirm key is Tab', () => {
renderTagInput({ customizedConfirmKey: 'Tab' })
expect(screen.getByPlaceholderText('common.model.params.stop_sequencesPlaceholder')).toBeInTheDocument()
})
it('should render custom placeholder when placeholder prop is provided', () => {
renderTagInput({ placeholder: 'Custom placeholder' })
expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument()
})
it('should hide input when add is disabled', () => {
renderTagInput({ disableAdd: true })
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should hide remove controls when remove is disabled', () => {
renderTagInput({ items: ['alpha'], disableRemove: true })
expect(screen.queryByTestId('remove-tag')).not.toBeInTheDocument()
})
it('should apply focused style in special mode when input is focused', async () => {
renderTagInput({ customizedConfirmKey: 'Tab' })
const input = screen.getByRole('textbox')
const inputContainer = input.parentElement
expect(inputContainer).toHaveClass('border-transparent')
await userEvent.click(input)
expect(inputContainer).toHaveClass('border-dashed')
})
})
describe('User Interactions', () => {
it('should remove item when remove control is clicked', async () => {
const { onChange } = renderTagInput({ items: ['alpha', 'beta'] })
const removeControl = screen.getAllByTestId('remove-tag')[0]
await userEvent.click(removeControl)
expect(onChange).toHaveBeenCalledWith(['beta'])
})
it('should add trimmed tag on Enter and clear input', async () => {
const { onChange } = renderTagInput()
const input = screen.getByRole('textbox')
await userEvent.type(input, ' new-tag ')
await userEvent.type(input, '{Enter}')
expect(onChange).toHaveBeenCalledWith(['new-tag'])
await waitFor(() => {
expect(input).toHaveValue('')
})
})
it('should add tag on blur when input has valid value', async () => {
const { onChange } = renderTagInput()
const input = screen.getByRole('textbox')
await userEvent.type(input, 'blur-tag')
await userEvent.click(document.body)
expect(onChange).toHaveBeenCalledWith(['blur-tag'])
})
it('should append return marker on Enter and confirm on Tab in special mode', async () => {
const user = userEvent.setup()
const { onChange } = renderTagInput({ customizedConfirmKey: 'Tab' })
const input = screen.getByRole('textbox')
// Type normally
await user.type(input, 'stop')
await user.keyboard('{Enter}')
expect(input).toHaveValue('stop↵')
expect(onChange).not.toHaveBeenCalled()
// Low-level test for preventDefault
const tabEvent = createEvent.keyDown(input, { key: 'Tab' })
tabEvent.preventDefault = vi.fn()
fireEvent(input, tabEvent)
expect(tabEvent.preventDefault).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(['stop↵'])
})
})
describe('Validation', () => {
it('should notify duplicate error when tag already exists', async () => {
const { onChange } = renderTagInput({ items: ['dup-tag'] })
const input = screen.getByRole('textbox')
await userEvent.type(input, 'dup-tag')
await userEvent.keyboard('{Enter}')
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordDuplicate',
})
})
it('should notify length error when tag is longer than 20 chars', async () => {
const { onChange } = renderTagInput()
const input = screen.getByRole('textbox')
await userEvent.type(input, 'a'.repeat(21))
await userEvent.keyboard('{Enter}')
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordError',
})
})
it('should notify required error when value is empty and required is true', async () => {
const { onChange } = renderTagInput({ required: true })
const input = screen.getByRole('textbox')
await userEvent.type(input, ' ')
await userEvent.click(document.body)
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetDocuments.segment.keywordEmpty',
})
})
it('should ignore empty value when required is false', async () => {
const { onChange } = renderTagInput({ required: false })
const input = screen.getByRole('textbox')
await userEvent.type(input, ' ')
await userEvent.click(document.body)
expect(onChange).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,5 +1,4 @@
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import { RiAddLine, RiCloseLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import AutosizeInput from 'react-18-input-autosize'
import { useTranslation } from 'react-i18next'
@@ -90,13 +89,13 @@ const TagInput: FC<TagInputProps> = ({
(items || []).map((item, index) => (
<div
key={item}
className={cn('system-xs-regular mr-1 mt-1 flex items-center rounded-md border border-divider-deep bg-components-badge-white-to-dark py-1 pl-1.5 pr-1 text-text-secondary')}
className={cn('mr-1 mt-1 flex items-center rounded-md border border-divider-deep bg-components-badge-white-to-dark py-1 pl-1.5 pr-1 text-text-secondary system-xs-regular')}
>
{item}
{
!disableRemove && (
<div className="flex h-4 w-4 cursor-pointer items-center justify-center" onClick={() => handleRemove(index)}>
<RiCloseLine className="ml-0.5 h-3.5 w-3.5 text-text-tertiary" />
<span className="i-ri-close-line ml-0.5 h-3.5 w-3.5 text-text-tertiary" data-testid="remove-tag" />
</div>
)
}
@@ -106,7 +105,7 @@ const TagInput: FC<TagInputProps> = ({
{
!disableAdd && (
<div className={cn('group/tag-add mt-1 flex items-center gap-x-0.5', !isSpecialMode ? 'rounded-md border border-dashed border-divider-deep px-1.5' : '')}>
{!isSpecialMode && !focused && <RiAddLine className="h-3.5 w-3.5 text-text-placeholder group-hover/tag-add:text-text-secondary" />}
{!isSpecialMode && !focused && <span className="i-ri-add-line h-3.5 w-3.5 text-text-placeholder group-hover/tag-add:text-text-secondary" />}
<AutosizeInput
inputClassName={cn(
'appearance-none text-text-primary caret-[#295EFF] outline-none placeholder:text-text-placeholder group-hover/tag-add:placeholder:text-text-secondary',
@@ -116,7 +115,7 @@ const TagInput: FC<TagInputProps> = ({
className={cn(
!isInWorkflow && 'max-w-[300px]',
isInWorkflow && 'max-w-[146px]',
'system-xs-regular overflow-hidden rounded-md py-1',
'overflow-hidden rounded-md py-1 system-xs-regular',
isSpecialMode && 'border border-transparent px-1.5',
focused && isSpecialMode && 'border-dashed border-divider-deep',
)}

View File

@@ -0,0 +1,167 @@
import type { IOtherOptions } from '@/service/base'
import { act, renderHook } from '@testing-library/react'
import { useTextGeneration } from './hooks'
const mockNotify = vi.fn()
const mockSsePost = vi.fn<(url: string, fetchOptions: { body: Record<string, unknown> }, otherOptions: IOtherOptions) => void>()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/service/base', () => ({
ssePost: (...args: Parameters<typeof mockSsePost>) => mockSsePost(...args),
}))
const getLatestStreamOptions = (): IOtherOptions => {
const latestCall = mockSsePost.mock.calls[mockSsePost.mock.calls.length - 1]
if (!latestCall)
throw new Error('Expected ssePost to be called at least once')
return latestCall[2]
}
describe('useTextGeneration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return expected initial state and handlers', () => {
const { result } = renderHook(() => useTextGeneration())
expect(result.current.completion).toBe('')
expect(result.current.isResponding).toBe(false)
expect(result.current.messageId).toBeNull()
expect(result.current.setIsResponding).toBeInstanceOf(Function)
expect(result.current.handleSend).toBeInstanceOf(Function)
})
})
describe('Send Flow', () => {
it('should start streaming request and return true when not responding', async () => {
const { result } = renderHook(() => useTextGeneration())
let sendResult: boolean | undefined
await act(async () => {
sendResult = await result.current.handleSend('/console/api', { query: 'hello' })
})
expect(sendResult).toBe(true)
expect(result.current.isResponding).toBe(true)
expect(result.current.completion).toBe('')
expect(result.current.messageId).toBe('')
expect(mockSsePost).toHaveBeenCalledWith(
'/console/api',
{
body: {
response_mode: 'streaming',
query: 'hello',
},
},
expect.objectContaining({
onData: expect.any(Function),
onMessageReplace: expect.any(Function),
onCompleted: expect.any(Function),
onError: expect.any(Function),
}),
)
})
it('should append chunks and update messageId when onData is triggered', async () => {
const { result } = renderHook(() => useTextGeneration())
await act(async () => {
await result.current.handleSend('/console/api', { query: 'chunk' })
})
const streamOptions = getLatestStreamOptions()
act(() => {
streamOptions.onData?.('Hello', true, { messageId: 'message-1' })
})
expect(result.current.completion).toBe('Hello')
expect(result.current.messageId).toBe('message-1')
act(() => {
streamOptions.onData?.(' world', false, { messageId: 'message-1' })
})
expect(result.current.completion).toBe('Hello world')
expect(result.current.messageId).toBe('message-1')
})
it('should replace completion when onMessageReplace is triggered', async () => {
const { result } = renderHook(() => useTextGeneration())
await act(async () => {
await result.current.handleSend('/console/api', { query: 'replace' })
})
const streamOptions = getLatestStreamOptions()
act(() => {
streamOptions.onData?.('Old content', true, { messageId: 'message-2' })
})
const replaceMessage = { answer: 'New content' } as Parameters<NonNullable<IOtherOptions['onMessageReplace']>>[0]
act(() => {
streamOptions.onMessageReplace?.(replaceMessage)
})
expect(result.current.completion).toBe('New content')
})
it('should set responding to false when stream completes', async () => {
const { result } = renderHook(() => useTextGeneration())
await act(async () => {
await result.current.handleSend('/console/api', { query: 'done' })
})
expect(result.current.isResponding).toBe(true)
const streamOptions = getLatestStreamOptions()
act(() => {
streamOptions.onCompleted?.()
})
expect(result.current.isResponding).toBe(false)
})
it('should set responding to false when stream errors', async () => {
const { result } = renderHook(() => useTextGeneration())
await act(async () => {
await result.current.handleSend('/console/api', { query: 'error' })
})
expect(result.current.isResponding).toBe(true)
const streamOptions = getLatestStreamOptions()
act(() => {
streamOptions.onError?.('something went wrong')
})
expect(result.current.isResponding).toBe(false)
})
it('should notify and return false when called while already responding', async () => {
const { result } = renderHook(() => useTextGeneration())
let sendResult: boolean | undefined
act(() => {
result.current.setIsResponding(true)
})
await act(async () => {
sendResult = await result.current.handleSend('/console/api', { query: 'wait' })
})
expect(sendResult).toBe(false)
expect(mockSsePost).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'info',
message: 'appDebug.errorMessage.waitForResponse',
})
})
})
})

View File

@@ -2607,11 +2607,6 @@
"count": 1
}
},
"app/components/base/tag-input/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/tag-management/index.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 1