mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add tests for some base components (#32415)
This commit is contained in:
237
web/app/components/base/app-icon-picker/ImageInput.spec.tsx
Normal file
237
web/app/components/base/app-icon-picker/ImageInput.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })}
|
||||
|
||||
|
||||
</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>
|
||||
|
||||
120
web/app/components/base/app-icon-picker/hooks.spec.tsx
Normal file
120
web/app/components/base/app-icon-picker/hooks.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
339
web/app/components/base/app-icon-picker/index.spec.tsx
Normal file
339
web/app/components/base/app-icon-picker/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
364
web/app/components/base/app-icon-picker/utils.spec.ts
Normal file
364
web/app/components/base/app-icon-picker/utils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
62
web/app/components/base/grid-mask/index.spec.tsx
Normal file
62
web/app/components/base/grid-mask/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
144
web/app/components/base/image-gallery/index.spec.tsx
Normal file
144
web/app/components/base/image-gallery/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 */}
|
||||
|
||||
40
web/app/components/base/param-item/index-slider.spec.tsx
Normal file
40
web/app/components/base/param-item/index-slider.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
179
web/app/components/base/param-item/index.spec.tsx
Normal file
179
web/app/components/base/param-item/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
145
web/app/components/base/param-item/score-threshold-item.spec.tsx
Normal file
145
web/app/components/base/param-item/score-threshold-item.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
130
web/app/components/base/param-item/top-k-item.spec.tsx
Normal file
130
web/app/components/base/param-item/top-k-item.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
187
web/app/components/base/tag-input/index.spec.tsx
Normal file
187
web/app/components/base/tag-input/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
167
web/app/components/base/text-generation/hooks.spec.ts
Normal file
167
web/app/components/base/text-generation/hooks.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user