From 0358925d7def96cf34b65565e5ec4b7675efc8c2 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:38:57 +0530 Subject: [PATCH] test: add tests for some base components (#32415) --- .../base/app-icon-picker/ImageInput.spec.tsx | 237 ++++++++++++ .../base/app-icon-picker/ImageInput.tsx | 6 +- .../base/app-icon-picker/hooks.spec.tsx | 120 ++++++ .../base/app-icon-picker/index.spec.tsx | 339 ++++++++++++++++ .../base/app-icon-picker/utils.spec.ts | 364 ++++++++++++++++++ .../components/base/grid-mask/index.spec.tsx | 62 +++ .../base/image-gallery/index.spec.tsx | 144 +++++++ .../base/image-uploader/image-preview.tsx | 1 + .../base/param-item/index-slider.spec.tsx | 40 ++ .../components/base/param-item/index.spec.tsx | 179 +++++++++ .../param-item/score-threshold-item.spec.tsx | 145 +++++++ .../base/param-item/score-threshold-item.tsx | 7 +- .../base/param-item/top-k-item.spec.tsx | 130 +++++++ .../components/base/tag-input/index.spec.tsx | 187 +++++++++ web/app/components/base/tag-input/index.tsx | 9 +- .../base/text-generation/hooks.spec.ts | 167 ++++++++ web/eslint-suppressions.json | 5 - 17 files changed, 2129 insertions(+), 13 deletions(-) create mode 100644 web/app/components/base/app-icon-picker/ImageInput.spec.tsx create mode 100644 web/app/components/base/app-icon-picker/hooks.spec.tsx create mode 100644 web/app/components/base/app-icon-picker/index.spec.tsx create mode 100644 web/app/components/base/app-icon-picker/utils.spec.ts create mode 100644 web/app/components/base/grid-mask/index.spec.tsx create mode 100644 web/app/components/base/image-gallery/index.spec.tsx create mode 100644 web/app/components/base/param-item/index-slider.spec.tsx create mode 100644 web/app/components/base/param-item/index.spec.tsx create mode 100644 web/app/components/base/param-item/score-threshold-item.spec.tsx create mode 100644 web/app/components/base/param-item/top-k-item.spec.tsx create mode 100644 web/app/components/base/tag-input/index.spec.tsx create mode 100644 web/app/components/base/text-generation/hooks.spec.ts diff --git a/web/app/components/base/app-icon-picker/ImageInput.spec.tsx b/web/app/components/base/app-icon-picker/ImageInput.spec.tsx new file mode 100644 index 0000000000..8e0476823a --- /dev/null +++ b/web/app/components/base/app-icon-picker/ImageInput.spec.tsx @@ -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() + + 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() + + const input = screen.getByTestId('image-input') + expect(input).toBeInTheDocument() + expect(input).toHaveClass('hidden') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('my-custom-class') + }) + }) + + describe('User Interactions', () => { + it('should trigger file input click when browse button is clicked', () => { + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + unmount() + expect(revokeObjectURLMock).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should not crash when onImageInput is not provided', async () => { + render() + + 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() + + 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') + }) + }) +}) diff --git a/web/app/components/base/app-icon-picker/ImageInput.tsx b/web/app/components/base/app-icon-picker/ImageInput.tsx index d41f3bf232..e255b2cfe6 100644 --- a/web/app/components/base/app-icon-picker/ImageInput.tsx +++ b/web/app/components/base/app-icon-picker/ImageInput.tsx @@ -72,7 +72,8 @@ const ImageInput: FC = ({ const handleShowImage = () => { if (isAnimatedImage) { return ( - + // eslint-disable-next-line next/no-img-element + ) } @@ -107,7 +108,7 @@ const ImageInput: FC = ({
{t('imageInput.dropImageHere', { ns: 'common' })} -  +   = ({ onClick={e => ((e.target as HTMLInputElement).value = '')} accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')} onChange={handleLocalFileInput} + data-testid="image-input" />
{t('imageInput.supportedFormats', { ns: 'common' })}
diff --git a/web/app/components/base/app-icon-picker/hooks.spec.tsx b/web/app/components/base/app-icon-picker/hooks.spec.tsx new file mode 100644 index 0000000000..58741a3ecf --- /dev/null +++ b/web/app/components/base/app-icon-picker/hooks.spec.tsx @@ -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 => ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { files: [] as unknown as FileList }, + ...overrides, +} as unknown as React.DragEvent) + +describe('useDraggableUploader', () => { + let setImageFn: ReturnType 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) + }) + }) +}) diff --git a/web/app/components/base/app-icon-picker/index.spec.tsx b/web/app/components/base/app-icon-picker/index.spec.tsx new file mode 100644 index 0000000000..63d447e289 --- /dev/null +++ b/web/app/components/base/app-icon-picker/index.spec.tsx @@ -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 = {} + + 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 => ({ + 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 }) => ( +
+ +
+ ), +})) + +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) => { + if (args[0] === 'canvas') { + const nextCanvas = canvases.shift() + if (!nextCanvas) + throw new Error('Unexpected canvas creation') + return nextCanvas as ReturnType + } + return originalCreateElement(...args) + }) + } + + const renderPicker = () => { + const onSelect = vi.fn() + const onClose = vi.fn() + + const { container } = render() + + 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() + }) + }) +}) diff --git a/web/app/components/base/app-icon-picker/utils.spec.ts b/web/app/components/base/app-icon-picker/utils.spec.ts new file mode 100644 index 0000000000..778d384910 --- /dev/null +++ b/web/app/components/base/app-icon-picker/utils.spec.ts @@ -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 = {} + + 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 + toBlobMock: ReturnType +} + +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) => { + if (args[0] === 'canvas') { + const nextCanvas = canvases.shift() + if (!nextCanvas) + throw new Error('Unexpected canvas creation') + return nextCanvas as ReturnType + } + 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) => void) | null = null + onload: ((event: ProgressEvent) => void) | null = null + + readAsArrayBuffer(_blob: Blob) { + const errorEvent = new ProgressEvent('error') as ProgressEvent + setTimeout(() => { + this.onerror?.(errorEvent) + }, 0) + } + } as unknown as typeof FileReader + + await expect(checkIsAnimatedImage(file)).rejects.toBeInstanceOf(ProgressEvent) + }) + }) +}) diff --git a/web/app/components/base/grid-mask/index.spec.tsx b/web/app/components/base/grid-mask/index.spec.tsx new file mode 100644 index 0000000000..28d806a69b --- /dev/null +++ b/web/app/components/base/grid-mask/index.spec.tsx @@ -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> = {}, children: React.ReactNode = Child) { + const { container } = render({children}) + 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({}, ) + expect(screen.getByRole('button', { name: 'Run' })).toBeInTheDocument() + }) + + it('should render correctly without optional className props', () => { + const { wrapper, canvasLayer, gradientLayer, contentLayer } = renderGridMask({}, Plain child) + + 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({}, Content) + 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' }, Child) + 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' }, Child) + + expect(canvasLayer).toHaveClass('custom-canvas') + expect(canvasLayer).toHaveClass(Style.gridBg) + }) + + it('should apply gradientClassName to gradient layer', () => { + const { gradientLayer } = renderGridMask({ gradientClassName: 'custom-gradient' }, Child) + + expect(gradientLayer).toHaveClass('custom-gradient') + expect(gradientLayer).toHaveClass('bg-grid-mask-background') + }) + }) +}) diff --git a/web/app/components/base/image-gallery/index.spec.tsx b/web/app/components/base/image-gallery/index.spec.tsx new file mode 100644 index 0000000000..96967b541c --- /dev/null +++ b/web/app/components/base/image-gallery/index.spec.tsx @@ -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() + + 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() + + 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() + + expect(getImages(container)).toHaveLength(2) + }) + + it('should render no images when srcs is empty', () => { + const { container } = render() + + expect(getImages(container)).toHaveLength(0) + }) + + it('should not render ImagePreview initially', () => { + render() + + expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument() + }) + }) + + describe('Width Styles', () => { + it('should apply maxWidth 100% for a single image', () => { + const { container } = render() + + const img = getImages(container)[0] + expect(img.style.maxWidth).toBe('100%') + }) + + it('should apply calc(50% - 4px) width for 2 images', () => { + const { container } = render() + + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + 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() + + 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() + + const img = getImages(container)[0] + fireEvent.error(img) + + expect(getImages(container)).toHaveLength(0) + }) + }) +}) + +describe('ImageGalleryTest', () => { + it('should render multiple ImageGallery instances', () => { + const { container } = render() + + const imgs = getImages(container) + // 6 images renders galleries with 1+2+3+4+5+6 = 21 images total + expect(imgs.length).toBe(21) + }) +}) diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index cffbde2755..54a5fabf9c 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -196,6 +196,7 @@ const ImagePreview: FC = ({ onMouseUp={handleMouseUp} style={{ cursor: scale > 1 ? 'move' : 'default' }} tabIndex={-1} + data-testid="image-preview-container" > { } {/* eslint-disable-next-line next/no-img-element */} diff --git a/web/app/components/base/param-item/index-slider.spec.tsx b/web/app/components/base/param-item/index-slider.spec.tsx new file mode 100644 index 0000000000..b0fa28a2d5 --- /dev/null +++ b/web/app/components/base/param-item/index-slider.spec.tsx @@ -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() + 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() + 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) + }) +}) diff --git a/web/app/components/base/param-item/index.spec.tsx b/web/app/components/base/param-item/index.spec.tsx new file mode 100644 index 0000000000..45e0c2a5b3 --- /dev/null +++ b/web/app/components/base/param-item/index.spec.tsx @@ -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() + + expect(screen.getByText('Test Param')).toBeInTheDocument() + }) + + it('should render a tooltip trigger by default', () => { + const { container } = render() + + // 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() + + // No tooltip trigger icon should be rendered + expect(container.querySelector('[data-state]')).not.toBeInTheDocument() + }) + + it('should render a switch when hasSwitch is true', () => { + render() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should not render a switch by default', () => { + render() + + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) + + it('should render InputNumber and Slider', () => { + render() + + expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + + expect(container.firstChild).toHaveClass('my-custom-class') + }) + + it('should disable InputNumber when enable is false', () => { + render() + + expect(screen.getByRole('spinbutton')).toBeDisabled() + }) + + it('should disable Slider when enable is false', () => { + render() + + expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') + }) + + it('should set switch value based on enable prop', () => { + render() + + 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 ( + { + defaultProps.onChange(key, nextValue) + setValue(nextValue) + }} + /> + ) + } + + render() + 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() + 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() + 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() + + 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() + 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() + + // 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() + + 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() + 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') + }) + }) +}) diff --git a/web/app/components/base/param-item/score-threshold-item.spec.tsx b/web/app/components/base/param-item/score-threshold-item.spec.tsx new file mode 100644 index 0000000000..ce0a396249 --- /dev/null +++ b/web/app/components/base/param-item/score-threshold-item.spec.tsx @@ -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() + + expect(screen.getByText('appDebug.datasetConfig.score_threshold')).toBeInTheDocument() + }) + + it('should render tooltip trigger', () => { + const { container } = render() + + // Tooltip trigger icon should be rendered + expect(container.querySelector('[data-state]')).toBeInTheDocument() + }) + + it('should render InputNumber and Slider', () => { + render() + + expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + + expect(container.firstChild).toHaveClass('custom-cls') + }) + + it('should render switch when hasSwitch is true', () => { + render() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should forward onSwitchChange to ParamItem', async () => { + const onSwitchChange = vi.fn() + render() + + // 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() + + 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() + const input = screen.getByRole('spinbutton') + + expect(input).toHaveAttribute('min', '0') + }) + + it('should clamp values to maximum of 1', () => { + render() + const input = screen.getByRole('spinbutton') + + expect(input).toHaveAttribute('max', '1') + }) + + it('should use step of 0.01', () => { + render() + 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 ( + { + defaultProps.onChange(key, nextValue) + setValue(nextValue) + }} + /> + ) + } + + render() + 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() + 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() + 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() + const input = screen.getByRole('spinbutton') + expect(input).toHaveValue(1) + }) + }) +}) diff --git a/web/app/components/base/param-item/score-threshold-item.tsx b/web/app/components/base/param-item/score-threshold-item.tsx index 91b1cf6b79..c6c73713d7 100644 --- a/web/app/components/base/param-item/score-threshold-item.tsx +++ b/web/app/components/base/param-item/score-threshold-item.tsx @@ -35,6 +35,11 @@ const ScoreThresholdItem: FC = ({ 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 ( = ({ 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} diff --git a/web/app/components/base/param-item/top-k-item.spec.tsx b/web/app/components/base/param-item/top-k-item.spec.tsx new file mode 100644 index 0000000000..2031e4a83e --- /dev/null +++ b/web/app/components/base/param-item/top-k-item.spec.tsx @@ -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() + + expect(screen.getByText('appDebug.datasetConfig.top_k')).toBeInTheDocument() + }) + + it('should render tooltip trigger', () => { + const { container } = render() + + // Tooltip trigger icon should be rendered + expect(container.querySelector('[data-state]')).toBeInTheDocument() + }) + + it('should render InputNumber and Slider', () => { + render() + + expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + + expect(container.firstChild).toHaveClass('custom-cls') + }) + + it('should disable controls when enable is false', () => { + render() + + expect(screen.getByRole('spinbutton')).toBeDisabled() + expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') + }) + }) + + describe('Value Limits', () => { + it('should use step of 1', () => { + render() + const input = screen.getByRole('spinbutton') + + expect(input).toHaveAttribute('step', '1') + }) + + it('should use minimum of 1', () => { + render() + const input = screen.getByRole('spinbutton') + + expect(input).toHaveAttribute('min', '1') + }) + + it('should use maximum from env (10)', () => { + render() + const input = screen.getByRole('spinbutton') + + expect(input).toHaveAttribute('max', '10') + }) + + it('should render slider with max >= 5 so no scaling is applied', () => { + render() + 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() + + 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() + 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() + 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() + const slider = screen.getByRole('slider') + + await user.click(slider) + await user.keyboard('{ArrowRight}') + + expect(defaultProps.onChange).toHaveBeenLastCalledWith('top_k', 3) + }) + }) +}) diff --git a/web/app/components/base/tag-input/index.spec.tsx b/web/app/components/base/tag-input/index.spec.tsx new file mode 100644 index 0000000000..077f938570 --- /dev/null +++ b/web/app/components/base/tag-input/index.spec.tsx @@ -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 + +const renderTagInput = (props: Partial = {}) => { + const onChange = vi.fn<(items: string[]) => void>() + const items = props.items ?? [] + + render() + + 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() + }) + }) +}) diff --git a/web/app/components/base/tag-input/index.tsx b/web/app/components/base/tag-input/index.tsx index e291842a2a..1c49b026fb 100644 --- a/web/app/components/base/tag-input/index.tsx +++ b/web/app/components/base/tag-input/index.tsx @@ -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 = ({ (items || []).map((item, index) => (
{item} { !disableRemove && (
handleRemove(index)}> - +
) } @@ -106,7 +105,7 @@ const TagInput: FC = ({ { !disableAdd && (
- {!isSpecialMode && !focused && } + {!isSpecialMode && !focused && } = ({ 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', )} diff --git a/web/app/components/base/text-generation/hooks.spec.ts b/web/app/components/base/text-generation/hooks.spec.ts new file mode 100644 index 0000000000..f25dd3b945 --- /dev/null +++ b/web/app/components/base/text-generation/hooks.spec.ts @@ -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 }, otherOptions: IOtherOptions) => void>() + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('@/service/base', () => ({ + ssePost: (...args: Parameters) => 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>[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', + }) + }) + }) +}) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 9bb6b15490..d142f1c556 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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