From d7b8db2afc8a2aac769b1a225465c2328b5469e9 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 19 Dec 2025 15:21:21 +0800 Subject: [PATCH] feat(tests): add comprehensive tests for Processing and EmbeddingProcess components (#29873) Co-authored-by: CodingOnStar Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/__mocks__/ky.ts | 71 + web/app/components/apps/app-card.spec.tsx | 364 ++++- web/app/components/apps/list.spec.tsx | 540 ++++--- .../actions/index.spec.tsx | 825 +++++++++++ .../preview/chunk-preview.spec.tsx | 461 ++++++ .../preview/file-preview.spec.tsx | 320 +++++ .../preview/online-document-preview.spec.tsx | 359 +++++ .../preview/web-preview.spec.tsx | 256 ++++ .../process-documents/components.spec.tsx | 861 +++++++++++ .../process-documents/index.spec.tsx | 601 ++++++++ .../embedding-process/index.spec.tsx | 1260 +++++++++++++++++ .../embedding-process/rule-detail.spec.tsx | 475 +++++++ .../embedding-process/rule-detail.tsx | 2 +- .../processing/index.spec.tsx | 808 +++++++++++ .../create-from-pipeline/processing/index.tsx | 2 +- .../completed/segment-card/index.spec.tsx | 7 +- .../explore/create-app-modal/index.spec.tsx | 10 - .../components/workflow-header/index.spec.tsx | 18 - web/jest.config.ts | 4 + 19 files changed, 7015 insertions(+), 229 deletions(-) create mode 100644 web/__mocks__/ky.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx diff --git a/web/__mocks__/ky.ts b/web/__mocks__/ky.ts new file mode 100644 index 0000000000..6c7691f2cf --- /dev/null +++ b/web/__mocks__/ky.ts @@ -0,0 +1,71 @@ +/** + * Mock for ky HTTP client + * This mock is used to avoid ESM issues in Jest tests + */ + +type KyResponse = { + ok: boolean + status: number + statusText: string + headers: Headers + json: jest.Mock + text: jest.Mock + blob: jest.Mock + arrayBuffer: jest.Mock + clone: jest.Mock +} + +type KyInstance = jest.Mock & { + get: jest.Mock + post: jest.Mock + put: jest.Mock + patch: jest.Mock + delete: jest.Mock + head: jest.Mock + create: jest.Mock + extend: jest.Mock + stop: symbol +} + +const createResponse = (data: unknown = {}, status = 200): KyResponse => { + const response: KyResponse = { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: new Headers(), + json: jest.fn().mockResolvedValue(data), + text: jest.fn().mockResolvedValue(JSON.stringify(data)), + blob: jest.fn().mockResolvedValue(new Blob()), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), + clone: jest.fn(), + } + // Ensure clone returns a new response-like object, not the same instance + response.clone.mockImplementation(() => createResponse(data, status)) + return response +} + +const createKyInstance = (): KyInstance => { + const instance = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) as KyInstance + + // HTTP methods + instance.get = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.post = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.put = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.patch = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.delete = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.head = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + + // Create new instance with custom options + instance.create = jest.fn().mockImplementation(() => createKyInstance()) + instance.extend = jest.fn().mockImplementation(() => createKyInstance()) + + // Stop method for AbortController + instance.stop = Symbol('stop') + + return instance +} + +const ky = createKyInstance() + +export default ky +export { ky } diff --git a/web/app/components/apps/app-card.spec.tsx b/web/app/components/apps/app-card.spec.tsx index 40aa66075d..f7ff525ed2 100644 --- a/web/app/components/apps/app-card.spec.tsx +++ b/web/app/components/apps/app-card.spec.tsx @@ -42,11 +42,12 @@ jest.mock('@/context/provider-context', () => ({ }), })) -// Mock global public store +// Mock global public store - allow dynamic configuration +let mockWebappAuthEnabled = false jest.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: (selector: (s: any) => any) => selector({ systemFeatures: { - webapp_auth: { enabled: false }, + webapp_auth: { enabled: mockWebappAuthEnabled }, branding: { enabled: false }, }, }), @@ -79,8 +80,9 @@ jest.mock('@/service/access-control', () => ({ })) // Mock hooks +const mockOpenAsyncWindow = jest.fn() jest.mock('@/hooks/use-async-window-open', () => ({ - useAsyncWindowOpen: () => jest.fn(), + useAsyncWindowOpen: () => mockOpenAsyncWindow, })) // Mock utils @@ -178,21 +180,10 @@ jest.mock('next/dynamic', () => { } }) -/** - * Mock components that require special handling in test environment. - * - * Per frontend testing skills (mocking.md), we should NOT mock simple base components. - * However, the following require mocking due to: - * - Portal-based rendering that doesn't work well in happy-dom - * - Deep dependency chains importing ES modules (like ky) incompatible with Jest - * - Complex state management that requires controlled test behavior - */ - -// Popover uses portals for positioning which requires mocking in happy-dom environment +// Popover uses @headlessui/react portals - mock for controlled interaction testing jest.mock('@/app/components/base/popover', () => { const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => { const [isOpen, setIsOpen] = React.useState(false) - // Call btnClassName to cover lines 430-433 const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : '' return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', { @@ -210,13 +201,13 @@ jest.mock('@/app/components/base/popover', () => { return { __esModule: true, default: MockPopover } }) -// Tooltip uses portals for positioning - minimal mock preserving popup content as title attribute +// Tooltip uses portals - minimal mock preserving popup content as title attribute jest.mock('@/app/components/base/tooltip', () => ({ __esModule: true, default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children), })) -// TagSelector imports service/tag which depends on ky ES module - mock to avoid Jest ES module issues +// TagSelector has API dependency (service/tag) - mock for isolated testing jest.mock('@/app/components/base/tag-management/selector', () => ({ __esModule: true, default: ({ tags }: any) => { @@ -227,7 +218,7 @@ jest.mock('@/app/components/base/tag-management/selector', () => ({ }, })) -// AppTypeIcon has complex icon mapping logic - mock for focused component testing +// AppTypeIcon has complex icon mapping - mock for focused component testing jest.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) @@ -278,6 +269,8 @@ describe('AppCard', () => { beforeEach(() => { jest.clearAllMocks() + mockOpenAsyncWindow.mockReset() + mockWebappAuthEnabled = false }) describe('Rendering', () => { @@ -536,6 +529,46 @@ describe('AppCard', () => { expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() }) }) + + it('should close edit modal when onHide is called', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.editApp')) + }) + + await waitFor(() => { + expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onHide + fireEvent.click(screen.getByTestId('close-edit-modal')) + + await waitFor(() => { + expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument() + }) + }) + + it('should close duplicate modal when onHide is called', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.duplicate')) + }) + + await waitFor(() => { + expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onHide + fireEvent.click(screen.getByTestId('close-duplicate-modal')) + + await waitFor(() => { + expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument() + }) + }) }) describe('Styling', () => { @@ -852,6 +885,31 @@ describe('AppCard', () => { expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled() }) }) + + it('should close DSL export modal when onClose is called', async () => { + (workflowService.fetchWorkflowDraft as jest.Mock).mockResolvedValueOnce({ + environment_variables: [{ value_type: 'secret', name: 'API_KEY' }], + }) + + const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.export')) + }) + + await waitFor(() => { + expect(screen.getByTestId('dsl-export-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onClose + fireEvent.click(screen.getByTestId('close-dsl-export')) + + await waitFor(() => { + expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument() + }) + }) }) describe('Edge Cases', () => { @@ -1054,6 +1112,276 @@ describe('AppCard', () => { const tagSelector = screen.getByLabelText('tag-selector') expect(tagSelector).toBeInTheDocument() + + // Click on tag selector wrapper to trigger stopPropagation + const tagSelectorWrapper = tagSelector.closest('div') + if (tagSelectorWrapper) + fireEvent.click(tagSelectorWrapper) + }) + + it('should handle popover mouse leave', async () => { + render() + + // Open popover + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByTestId('popover-content')).toBeInTheDocument() + }) + + // Trigger mouse leave on the outer popover-content + fireEvent.mouseLeave(screen.getByTestId('popover-content')) + + await waitFor(() => { + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) + }) + + it('should handle operations menu mouse leave', async () => { + render() + + // Open popover + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.editApp')).toBeInTheDocument() + }) + + // Find the Operations wrapper div (contains the menu items) + const editButton = screen.getByText('app.editApp') + const operationsWrapper = editButton.closest('div.relative') + + // Trigger mouse leave on the Operations wrapper to call onMouseLeave + if (operationsWrapper) + fireEvent.mouseLeave(operationsWrapper) + }) + + it('should click open in explore button', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + // Verify openAsyncWindow was called with callback and options + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ onError: expect.any(Function) }), + ) + }) + }) + + it('should handle open in explore via async window', async () => { + // Configure mockOpenAsyncWindow to actually call the callback + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => { + await callback() + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + const { fetchInstalledAppList } = require('@/service/explore') + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalledWith(mockApp.id) + }) + }) + + it('should handle open in explore API failure', async () => { + const { fetchInstalledAppList } = require('@/service/explore') + fetchInstalledAppList.mockRejectedValueOnce(new Error('API Error')) + + // Configure mockOpenAsyncWindow to call the callback and trigger error + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => { + try { + await callback() + } + catch (err) { + options?.onError?.(err) + } + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalled() + }) + }) + }) + + describe('Access Control', () => { + it('should render operations menu correctly', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.editApp')).toBeInTheDocument() + expect(screen.getByText('app.duplicate')).toBeInTheDocument() + expect(screen.getByText('app.export')).toBeInTheDocument() + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + }) + }) + }) + + describe('Open in Explore - No App Found', () => { + it('should handle case when installed_apps is empty array', async () => { + const { fetchInstalledAppList } = require('@/service/explore') + fetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [] }) + + // Configure mockOpenAsyncWindow to call the callback and trigger error + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => { + try { + await callback() + } + catch (err) { + options?.onError?.(err) + } + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalled() + }) + }) + + it('should handle case when API throws in callback', async () => { + const { fetchInstalledAppList } = require('@/service/explore') + fetchInstalledAppList.mockRejectedValueOnce(new Error('Network error')) + + // Configure mockOpenAsyncWindow to call the callback without catching + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => { + return await callback() + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalled() + }) + }) + }) + + describe('Draft Trigger Apps', () => { + it('should not show open in explore option for apps with has_draft_trigger', async () => { + const draftTriggerApp = createMockApp({ has_draft_trigger: true }) + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.editApp')).toBeInTheDocument() + // openInExplore should not be shown for draft trigger apps + expect(screen.queryByText('app.openInExplore')).not.toBeInTheDocument() + }) + }) + }) + + describe('Non-editor User', () => { + it('should handle non-editor workspace users', () => { + // This tests the isCurrentWorkspaceEditor=true branch (default mock) + render() + expect(screen.getByTitle('Test App')).toBeInTheDocument() + }) + }) + + describe('WebApp Auth Enabled', () => { + beforeEach(() => { + mockWebappAuthEnabled = true + }) + + it('should show access control option when webapp_auth is enabled', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.accessControl')).toBeInTheDocument() + }) + }) + + it('should click access control button', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const accessControlBtn = screen.getByText('app.accessControl') + fireEvent.click(accessControlBtn) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + }) + + it('should close access control modal and call onRefresh', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.accessControl')) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + + // Confirm access control + fireEvent.click(screen.getByTestId('confirm-access-control')) + + await waitFor(() => { + expect(mockOnRefresh).toHaveBeenCalled() + }) + }) + + it('should show open in explore when userCanAccessApp is true', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.openInExplore')).toBeInTheDocument() + }) + }) + + it('should close access control modal when onClose is called', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.accessControl')) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onClose + fireEvent.click(screen.getByTestId('close-access-control')) + + await waitFor(() => { + expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() + }) }) }) }) diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index fe664a4a50..3bc8a27375 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import { AppModeEnum } from '@/types/app' // Mock next/navigation @@ -28,20 +28,29 @@ jest.mock('@/context/global-public-context', () => ({ }), })) -// Mock custom hooks +// Mock custom hooks - allow dynamic query state const mockSetQuery = jest.fn() +const mockQueryState = { + tagIDs: [] as string[], + keywords: '', + isCreatedByMe: false, +} jest.mock('./hooks/use-apps-query-state', () => ({ __esModule: true, default: () => ({ - query: { tagIDs: [], keywords: '', isCreatedByMe: false }, + query: mockQueryState, setQuery: mockSetQuery, }), })) +// Store callback for testing DSL file drop +let mockOnDSLFileDropped: ((file: File) => void) | null = null +let mockDragging = false jest.mock('./hooks/use-dsl-drag-drop', () => ({ - useDSLDragDrop: () => ({ - dragging: false, - }), + useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => { + mockOnDSLFileDropped = onDSLFileDropped + return { dragging: mockDragging } + }, })) const mockSetActiveTab = jest.fn() @@ -49,55 +58,90 @@ jest.mock('@/hooks/use-tab-searchparams', () => ({ useTabSearchParams: () => ['all', mockSetActiveTab], })) -// Mock service hooks +// Mock service hooks - use object for mutable state (jest.mock is hoisted) const mockRefetch = jest.fn() +const mockFetchNextPage = jest.fn() + +const mockServiceState = { + error: null as Error | null, + hasNextPage: false, + isLoading: false, + isFetchingNextPage: false, +} + +const defaultAppData = { + pages: [{ + data: [ + { + id: 'app-1', + name: 'Test App 1', + description: 'Description 1', + mode: AppModeEnum.CHAT, + icon: '๐Ÿค–', + icon_type: 'emoji', + icon_background: '#FFEAD5', + tags: [], + author_name: 'Author 1', + created_at: 1704067200, + updated_at: 1704153600, + }, + { + id: 'app-2', + name: 'Test App 2', + description: 'Description 2', + mode: AppModeEnum.WORKFLOW, + icon: 'โš™๏ธ', + icon_type: 'emoji', + icon_background: '#E4FBCC', + tags: [], + author_name: 'Author 2', + created_at: 1704067200, + updated_at: 1704153600, + }, + ], + total: 2, + }], +} + jest.mock('@/service/use-apps', () => ({ useInfiniteAppList: () => ({ - data: { - pages: [{ - data: [ - { - id: 'app-1', - name: 'Test App 1', - description: 'Description 1', - mode: AppModeEnum.CHAT, - icon: '๐Ÿค–', - icon_type: 'emoji', - icon_background: '#FFEAD5', - tags: [], - author_name: 'Author 1', - created_at: 1704067200, - updated_at: 1704153600, - }, - { - id: 'app-2', - name: 'Test App 2', - description: 'Description 2', - mode: AppModeEnum.WORKFLOW, - icon: 'โš™๏ธ', - icon_type: 'emoji', - icon_background: '#E4FBCC', - tags: [], - author_name: 'Author 2', - created_at: 1704067200, - updated_at: 1704153600, - }, - ], - total: 2, - }], - }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: jest.fn(), - hasNextPage: false, - error: null, + data: defaultAppData, + isLoading: mockServiceState.isLoading, + isFetchingNextPage: mockServiceState.isFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockServiceState.hasNextPage, + error: mockServiceState.error, refetch: mockRefetch, }), })) // Mock tag store jest.mock('@/app/components/base/tag-management/store', () => ({ - useStore: () => false, + useStore: (selector: (state: { tagList: any[]; setTagList: any; showTagManagementModal: boolean; setShowTagManagementModal: any }) => any) => { + const state = { + tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app' }], + setTagList: jest.fn(), + showTagManagementModal: false, + setShowTagManagementModal: jest.fn(), + } + return selector(state) + }, +})) + +// Mock tag service to avoid API calls in TagFilter +jest.mock('@/service/tag', () => ({ + fetchTagList: jest.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]), +})) + +// Store TagFilter onChange callback for testing +let mockTagFilterOnChange: ((value: string[]) => void) | null = null +jest.mock('@/app/components/base/tag-management/filter', () => ({ + __esModule: true, + default: ({ onChange }: { onChange: (value: string[]) => void }) => { + const React = require('react') + mockTagFilterOnChange = onChange + return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder') + }, })) // Mock config @@ -110,9 +154,17 @@ jest.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) -// Mock debounce hook +// Mock ahooks - useMount only executes once on mount, not on fn change jest.mock('ahooks', () => ({ useDebounceFn: (fn: () => void) => ({ run: fn }), + useMount: (fn: () => void) => { + const React = require('react') + const fnRef = React.useRef(fn) + fnRef.current = fn + React.useEffect(() => { + fnRef.current() + }, []) + }, })) // Mock dynamic imports @@ -127,10 +179,11 @@ jest.mock('next/dynamic', () => { } } if (fnString.includes('create-from-dsl-modal')) { - return function MockCreateFromDSLModal({ show, onClose }: any) { + return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) { if (!show) return null return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), + React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'), ) } } @@ -174,127 +227,83 @@ jest.mock('./footer', () => ({ }, })) -/** - * Mock base components that have deep dependency chains or require controlled test behavior. - * - * Per frontend testing skills (mocking.md), we generally should NOT mock base components. - * However, the following require mocking due to: - * - Deep dependency chains importing ES modules (like ky) incompatible with Jest - * - Need for controlled interaction behavior in tests (onChange, onClear handlers) - * - Complex internal state that would make tests flaky - * - * These mocks preserve the component's props interface to test List's integration correctly. - */ -jest.mock('@/app/components/base/tab-slider-new', () => ({ - __esModule: true, - default: ({ value, onChange, options }: any) => { - const React = require('react') - return React.createElement('div', { 'data-testid': 'tab-slider', 'role': 'tablist' }, - options.map((opt: any) => - React.createElement('button', { - 'key': opt.value, - 'data-testid': `tab-${opt.value}`, - 'role': 'tab', - 'aria-selected': value === opt.value, - 'onClick': () => onChange(opt.value), - }, opt.text), - ), - ) - }, -})) - -jest.mock('@/app/components/base/input', () => ({ - __esModule: true, - default: ({ value, onChange, onClear }: any) => { - const React = require('react') - return React.createElement('div', { 'data-testid': 'search-input' }, - React.createElement('input', { - 'data-testid': 'search-input-field', - 'role': 'searchbox', - 'value': value || '', - onChange, - }), - React.createElement('button', { - 'data-testid': 'clear-search', - 'aria-label': 'Clear search', - 'onClick': onClear, - }, 'Clear'), - ) - }, -})) - -jest.mock('@/app/components/base/tag-management/filter', () => ({ - __esModule: true, - default: ({ value, onChange }: any) => { - const React = require('react') - return React.createElement('div', { 'data-testid': 'tag-filter', 'role': 'listbox' }, - React.createElement('button', { - 'data-testid': 'add-tag-filter', - 'onClick': () => onChange([...value, 'new-tag']), - }, 'Add Tag'), - ) - }, -})) - -jest.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({ - __esModule: true, - default: ({ label, isChecked, onChange }: any) => { - const React = require('react') - return React.createElement('label', { 'data-testid': 'created-by-me-checkbox' }, - React.createElement('input', { - 'type': 'checkbox', - 'role': 'checkbox', - 'checked': isChecked, - 'aria-checked': isChecked, - onChange, - 'data-testid': 'created-by-me-input', - }), - label, - ) - }, -})) - // Import after mocks import List from './list' +// Store IntersectionObserver callback +let intersectionCallback: IntersectionObserverCallback | null = null +const mockObserve = jest.fn() +const mockDisconnect = jest.fn() + +// Mock IntersectionObserver +beforeAll(() => { + globalThis.IntersectionObserver = class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback + } + + observe = mockObserve + disconnect = mockDisconnect + unobserve = jest.fn() + root = null + rootMargin = '' + thresholds = [] + takeRecords = () => [] + } as unknown as typeof IntersectionObserver +}) + describe('List', () => { beforeEach(() => { jest.clearAllMocks() mockIsCurrentWorkspaceEditor.mockReturnValue(true) mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) + mockDragging = false + mockOnDSLFileDropped = null + mockTagFilterOnChange = null + mockServiceState.error = null + mockServiceState.hasNextPage = false + mockServiceState.isLoading = false + mockServiceState.isFetchingNextPage = false + mockQueryState.tagIDs = [] + mockQueryState.keywords = '' + mockQueryState.isCreatedByMe = false + intersectionCallback = null localStorage.clear() }) describe('Rendering', () => { it('should render without crashing', () => { render() - expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + // Tab slider renders app type tabs + expect(screen.getByText('app.types.all')).toBeInTheDocument() }) it('should render tab slider with all app types', () => { render() - expect(screen.getByTestId('tab-all')).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() + expect(screen.getByText('app.types.workflow')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.agent')).toBeInTheDocument() + expect(screen.getByText('app.types.completion')).toBeInTheDocument() }) it('should render search input', () => { render() - expect(screen.getByTestId('search-input')).toBeInTheDocument() + // Input component renders a searchbox + expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render tag filter', () => { render() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + // Tag filter renders with placeholder text + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) it('should render created by me checkbox', () => { render() - expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument() + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() }) it('should render app cards when apps exist', () => { @@ -324,7 +333,7 @@ describe('List', () => { it('should call setActiveTab when tab is clicked', () => { render() - fireEvent.click(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)) + fireEvent.click(screen.getByText('app.types.workflow')) expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) }) @@ -332,7 +341,7 @@ describe('List', () => { it('should call setActiveTab for all tab', () => { render() - fireEvent.click(screen.getByTestId('tab-all')) + fireEvent.click(screen.getByText('app.types.all')) expect(mockSetActiveTab).toHaveBeenCalledWith('all') }) @@ -341,23 +350,38 @@ describe('List', () => { describe('Search Functionality', () => { it('should render search input field', () => { render() - expect(screen.getByTestId('search-input-field')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should handle search input change', () => { render() - const input = screen.getByTestId('search-input-field') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test search' } }) expect(mockSetQuery).toHaveBeenCalled() }) - it('should clear search when clear button is clicked', () => { + it('should handle search input interaction', () => { render() - fireEvent.click(screen.getByTestId('clear-search')) + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + it('should handle search clear button click', () => { + // Set initial keywords to make clear button visible + mockQueryState.keywords = 'existing search' + + render() + + // Find and click clear button (Input component uses .group class for clear icon container) + const clearButton = document.querySelector('.group') + expect(clearButton).toBeInTheDocument() + if (clearButton) + fireEvent.click(clearButton) + + // handleKeywordsChange should be called with empty string expect(mockSetQuery).toHaveBeenCalled() }) }) @@ -365,16 +389,14 @@ describe('List', () => { describe('Tag Filter', () => { it('should render tag filter component', () => { render() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) - it('should handle tag filter change', () => { + it('should render tag filter with placeholder', () => { render() - fireEvent.click(screen.getByTestId('add-tag-filter')) - - // Tag filter change triggers debounced setTagIDs - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + // Tag filter is rendered + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) }) @@ -387,7 +409,9 @@ describe('List', () => { it('should handle checkbox change', () => { render() - const checkbox = screen.getByTestId('created-by-me-input') + // Checkbox component uses data-testid="checkbox-{id}" + // CheckboxWithLabel doesn't pass testId, so id is undefined + const checkbox = screen.getByTestId('checkbox-undefined') fireEvent.click(checkbox) expect(mockSetQuery).toHaveBeenCalled() @@ -436,10 +460,10 @@ describe('List', () => { describe('Edge Cases', () => { it('should handle multiple renders without issues', () => { const { rerender } = render() - expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() rerender() - expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() }) it('should render app cards correctly', () => { @@ -452,9 +476,9 @@ describe('List', () => { it('should render with all filter options visible', () => { render() - expect(screen.getByTestId('search-input')).toBeInTheDocument() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() - expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() }) }) @@ -469,27 +493,27 @@ describe('List', () => { it('should render all app type tabs', () => { render() - expect(screen.getByTestId('tab-all')).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() + expect(screen.getByText('app.types.workflow')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.agent')).toBeInTheDocument() + expect(screen.getByText('app.types.completion')).toBeInTheDocument() }) it('should call setActiveTab for each app type', () => { render() - const appModes = [ - AppModeEnum.WORKFLOW, - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.CHAT, - AppModeEnum.AGENT_CHAT, - AppModeEnum.COMPLETION, + const appTypeTexts = [ + { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, + { mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' }, + { mode: AppModeEnum.CHAT, text: 'app.types.chatbot' }, + { mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' }, + { mode: AppModeEnum.COMPLETION, text: 'app.types.completion' }, ] - appModes.forEach((mode) => { - fireEvent.click(screen.getByTestId(`tab-${mode}`)) + appTypeTexts.forEach(({ mode, text }) => { + fireEvent.click(screen.getByText(text)) expect(mockSetActiveTab).toHaveBeenCalledWith(mode) }) }) @@ -499,7 +523,7 @@ describe('List', () => { it('should display search input with correct attributes', () => { render() - const input = screen.getByTestId('search-input-field') + const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('value', '') }) @@ -507,8 +531,7 @@ describe('List', () => { it('should have tag filter component', () => { render() - const tagFilter = screen.getByTestId('tag-filter') - expect(tagFilter).toBeInTheDocument() + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) it('should display created by me label', () => { @@ -547,18 +570,17 @@ describe('List', () => { // -------------------------------------------------------------------------- describe('Additional Coverage', () => { it('should render dragging state overlay when dragging', () => { - // Test dragging state is handled + mockDragging = true const { container } = render() - // Component should render successfully + // Component should render successfully with dragging state expect(container).toBeInTheDocument() }) it('should handle app mode filter in query params', () => { - // Test that different modes are handled in query render() - const workflowTab = screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`) + const workflowTab = screen.getByText('app.types.workflow') fireEvent.click(workflowTab) expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) @@ -570,4 +592,168 @@ describe('List', () => { expect(screen.getByTestId('new-app-card')).toBeInTheDocument() }) }) + + describe('DSL File Drop', () => { + it('should handle DSL file drop and show modal', () => { + render() + + // Simulate DSL file drop via the callback + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + // Modal should be shown + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + }) + + it('should close DSL modal when onClose is called', () => { + render() + + // Open modal via DSL file drop + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + + // Close modal + fireEvent.click(screen.getByTestId('close-dsl-modal')) + + expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() + }) + + it('should close DSL modal and refetch when onSuccess is called', () => { + render() + + // Open modal via DSL file drop + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + + // Click success button + fireEvent.click(screen.getByTestId('success-dsl-modal')) + + // Modal should be closed and refetch should be called + expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + + describe('Tag Filter Change', () => { + it('should handle tag filter value change', () => { + jest.useFakeTimers() + render() + + // TagFilter component is rendered + expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + + // Trigger tag filter change via captured callback + act(() => { + if (mockTagFilterOnChange) + mockTagFilterOnChange(['tag-1', 'tag-2']) + }) + + // Advance timers to trigger debounced setTagIDs + act(() => { + jest.advanceTimersByTime(500) + }) + + // setQuery should have been called with updated tagIDs + expect(mockSetQuery).toHaveBeenCalled() + + jest.useRealTimers() + }) + + it('should handle empty tag filter selection', () => { + jest.useFakeTimers() + render() + + // Trigger tag filter change with empty array + act(() => { + if (mockTagFilterOnChange) + mockTagFilterOnChange([]) + }) + + // Advance timers + act(() => { + jest.advanceTimersByTime(500) + }) + + expect(mockSetQuery).toHaveBeenCalled() + + jest.useRealTimers() + }) + }) + + describe('Infinite Scroll', () => { + it('should call fetchNextPage when intersection observer triggers', () => { + mockServiceState.hasNextPage = true + render() + + // Simulate intersection + if (intersectionCallback) { + act(() => { + intersectionCallback!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } + + expect(mockFetchNextPage).toHaveBeenCalled() + }) + + it('should not call fetchNextPage when not intersecting', () => { + mockServiceState.hasNextPage = true + render() + + // Simulate non-intersection + if (intersectionCallback) { + act(() => { + intersectionCallback!( + [{ isIntersecting: false } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } + + expect(mockFetchNextPage).not.toHaveBeenCalled() + }) + + it('should not call fetchNextPage when loading', () => { + mockServiceState.hasNextPage = true + mockServiceState.isLoading = true + render() + + if (intersectionCallback) { + act(() => { + intersectionCallback!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } + + expect(mockFetchNextPage).not.toHaveBeenCalled() + }) + }) + + describe('Error State', () => { + it('should handle error state in useEffect', () => { + mockServiceState.error = new Error('Test error') + const { container } = render() + + // Component should still render + expect(container).toBeInTheDocument() + // Disconnect should be called when there's an error (cleanup) + }) + }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx new file mode 100644 index 0000000000..e3076bd172 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx @@ -0,0 +1,825 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import Actions from './index' + +// ========================================== +// Mock External Dependencies +// ========================================== + +// Mock next/navigation - useParams returns datasetId +const mockDatasetId = 'test-dataset-id' +jest.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: mockDatasetId }), +})) + +// Mock next/link to capture href +jest.mock('next/link', () => { + return ({ children, href, replace }: { children: React.ReactNode; href: string; replace?: boolean }) => ( + + {children} + + ) +}) + +// ========================================== +// Test Suite +// ========================================== + +describe('Actions', () => { + // Default mock for required props + const defaultProps = { + handleNextStep: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + // Tests basic rendering functionality + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeInTheDocument() + }) + + it('should render cancel button with correct link', () => { + // Arrange & Act + render() + + // Assert + const cancelLink = screen.getByRole('link') + expect(cancelLink).toHaveAttribute('href', `/datasets/${mockDatasetId}/documents`) + expect(cancelLink).toHaveAttribute('data-replace', 'true') + }) + + it('should render next step button with arrow icon', () => { + // Arrange & Act + render() + + // Assert + const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) + expect(nextButton).toBeInTheDocument() + expect(nextButton.querySelector('svg')).toBeInTheDocument() + }) + + it('should render cancel button with correct translation key', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + }) + + it('should not render select all section by default', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + // Tests for prop variations and defaults + describe('disabled prop', () => { + it('should not disable next step button when disabled is false', () => { + // Arrange & Act + render() + + // Assert + const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) + expect(nextButton).not.toBeDisabled() + }) + + it('should disable next step button when disabled is true', () => { + // Arrange & Act + render() + + // Assert + const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) + expect(nextButton).toBeDisabled() + }) + + it('should not disable next step button when disabled is undefined', () => { + // Arrange & Act + render() + + // Assert + const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) + expect(nextButton).not.toBeDisabled() + }) + }) + + describe('showSelect prop', () => { + it('should show select all section when showSelect is true', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() + }) + + it('should hide select all section when showSelect is false', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + }) + + it('should hide select all section when showSelect defaults to false', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + }) + }) + + describe('tip prop', () => { + it('should show tip when showSelect is true and tip is provided', () => { + // Arrange + const tip = 'This is a helpful tip' + + // Act + render() + + // Assert + expect(screen.getByText(tip)).toBeInTheDocument() + expect(screen.getByTitle(tip)).toBeInTheDocument() + }) + + it('should not show tip when showSelect is false even if tip is provided', () => { + // Arrange + const tip = 'This is a helpful tip' + + // Act + render() + + // Assert + expect(screen.queryByText(tip)).not.toBeInTheDocument() + }) + + it('should not show tip when tip is empty string', () => { + // Arrange & Act + render() + + // Assert + const tipElements = screen.queryAllByTitle('') + // Empty tip should not render a tip element + expect(tipElements.length).toBe(0) + }) + + it('should use empty string as default tip value', () => { + // Arrange & Act + render() + + // Assert - tip container should not exist when tip defaults to empty string + const tipContainer = document.querySelector('.text-text-tertiary.truncate') + expect(tipContainer).not.toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Event Handlers Testing + // ========================================== + describe('User Interactions', () => { + // Tests for event handlers + it('should call handleNextStep when next button is clicked', () => { + // Arrange + const handleNextStep = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + // Assert + expect(handleNextStep).toHaveBeenCalledTimes(1) + }) + + it('should not call handleNextStep when next button is disabled and clicked', () => { + // Arrange + const handleNextStep = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + // Assert + expect(handleNextStep).not.toHaveBeenCalled() + }) + + it('should call onSelectAll when checkbox is clicked', () => { + // Arrange + const onSelectAll = jest.fn() + render( + , + ) + + // Act - find the checkbox container and click it + const selectAllLabel = screen.getByText('common.operation.selectAll') + const checkboxContainer = selectAllLabel.closest('.flex.shrink-0.items-center') + const checkbox = checkboxContainer?.querySelector('[class*="cursor-pointer"]') + if (checkbox) + fireEvent.click(checkbox) + + // Assert + expect(onSelectAll).toHaveBeenCalledTimes(1) + }) + }) + + // ========================================== + // Memoization Logic Testing + // ========================================== + describe('Memoization Logic', () => { + // Tests for useMemo hooks (indeterminate and checked) + describe('indeterminate calculation', () => { + it('should return false when showSelect is false', () => { + // Arrange & Act + render( + , + ) + + // Assert - checkbox not rendered, so can't check indeterminate directly + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + }) + + it('should return false when selectedOptions is undefined', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should not be indeterminate + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return false when totalOptions is undefined', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should exist + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return true when some options are selected (0 < selectedOptions < totalOptions)', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should render in indeterminate state + // The checkbox component renders IndeterminateIcon when indeterminate and not checked + const selectAllContainer = container.querySelector('.flex.shrink-0.items-center') + expect(selectAllContainer).toBeInTheDocument() + }) + + it('should return false when no options are selected (selectedOptions === 0)', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should be unchecked and not indeterminate + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return false when all options are selected (selectedOptions === totalOptions)', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should be checked, not indeterminate + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + }) + + describe('checked calculation', () => { + it('should return false when showSelect is false', () => { + // Arrange & Act + render( + , + ) + + // Assert - checkbox not rendered + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + }) + + it('should return false when selectedOptions is undefined', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return false when totalOptions is undefined', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return true when all options are selected (selectedOptions === totalOptions)', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should show checked state (RiCheckLine icon) + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return false when selectedOptions is 0', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should be unchecked + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should return false when not all options are selected', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - checkbox should be indeterminate, not checked + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Component Memoization Testing + // ========================================== + describe('Component Memoization', () => { + // Tests for React.memo behavior + it('should be wrapped with React.memo', () => { + // Assert - verify component has memo wrapper + expect(Actions.$$typeof).toBe(Symbol.for('react.memo')) + }) + + it('should not re-render when props are the same', () => { + // Arrange + const handleNextStep = jest.fn() + const props = { + handleNextStep, + disabled: false, + showSelect: true, + totalOptions: 5, + selectedOptions: 3, + onSelectAll: jest.fn(), + tip: 'Test tip', + } + + // Act + const { rerender } = render() + + // Re-render with same props + rerender() + + // Assert - component renders correctly after rerender + expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() + expect(screen.getByText('Test tip')).toBeInTheDocument() + }) + + it('should re-render when props change', () => { + // Arrange + const handleNextStep = jest.fn() + const initialProps = { + handleNextStep, + disabled: false, + showSelect: true, + totalOptions: 5, + selectedOptions: 0, + onSelectAll: jest.fn(), + tip: 'Initial tip', + } + + // Act + const { rerender } = render() + expect(screen.getByText('Initial tip')).toBeInTheDocument() + + // Rerender with different props + rerender() + + // Assert + expect(screen.getByText('Updated tip')).toBeInTheDocument() + expect(screen.queryByText('Initial tip')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases Testing + // ========================================== + describe('Edge Cases', () => { + // Tests for boundary conditions and unusual inputs + it('should handle totalOptions of 0', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - should render checkbox + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should handle very large totalOptions', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should handle very long tip text', () => { + // Arrange + const longTip = 'A'.repeat(500) + + // Act + render( + , + ) + + // Assert - tip should render with truncate class + const tipElement = screen.getByTitle(longTip) + expect(tipElement).toHaveClass('truncate') + }) + + it('should handle tip with special characters', () => { + // Arrange + const specialTip = ' & "quotes" \'apostrophes\'' + + // Act + render( + , + ) + + // Assert - special characters should be rendered safely + expect(screen.getByText(specialTip)).toBeInTheDocument() + }) + + it('should handle tip with unicode characters', () => { + // Arrange + const unicodeTip = '้€‰ไธญ 5 ไธชๆ–‡ไปถ๏ผŒๅ…ฑ 10MB ๐Ÿš€' + + // Act + render( + , + ) + + // Assert + expect(screen.getByText(unicodeTip)).toBeInTheDocument() + }) + + it('should handle selectedOptions greater than totalOptions', () => { + // This is an edge case that shouldn't happen but should be handled gracefully + // Arrange & Act + const { container } = render( + , + ) + + // Assert - should still render + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should handle negative selectedOptions', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - should still render (though this is an invalid state) + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should handle onSelectAll being undefined when showSelect is true', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - should render checkbox + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + + // Click should not throw + if (checkbox) + expect(() => fireEvent.click(checkbox)).not.toThrow() + }) + + it('should handle empty datasetId from params', () => { + // This test verifies the link is constructed even with empty datasetId + // Arrange & Act + render() + + // Assert - link should still be present with the mocked datasetId + const cancelLink = screen.getByRole('link') + expect(cancelLink).toHaveAttribute('href', '/datasets/test-dataset-id/documents') + }) + }) + + // ========================================== + // All Prop Combinations Testing + // ========================================== + describe('Prop Combinations', () => { + // Tests for various combinations of props + it('should handle disabled=true with showSelect=false', () => { + // Arrange & Act + render() + + // Assert + const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) + expect(nextButton).toBeDisabled() + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + }) + + it('should handle disabled=true with showSelect=true', () => { + // Arrange & Act + render( + , + ) + + // Assert + const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) + expect(nextButton).toBeDisabled() + expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() + }) + + it('should render complete component with all props provided', () => { + // Arrange + const allProps = { + disabled: false, + handleNextStep: jest.fn(), + showSelect: true, + totalOptions: 10, + selectedOptions: 5, + onSelectAll: jest.fn(), + tip: 'All props provided', + } + + // Act + render() + + // Assert + expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() + expect(screen.getByText('All props provided')).toBeInTheDocument() + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + }) + + it('should render minimal component with only required props', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + }) + }) + + // ========================================== + // Selection State Variations Testing + // ========================================== + describe('Selection State Variations', () => { + // Tests for different selection states + const selectionStates = [ + { totalOptions: 10, selectedOptions: 0, expectedState: 'unchecked' }, + { totalOptions: 10, selectedOptions: 5, expectedState: 'indeterminate' }, + { totalOptions: 10, selectedOptions: 10, expectedState: 'checked' }, + { totalOptions: 1, selectedOptions: 0, expectedState: 'unchecked' }, + { totalOptions: 1, selectedOptions: 1, expectedState: 'checked' }, + { totalOptions: 100, selectedOptions: 1, expectedState: 'indeterminate' }, + { totalOptions: 100, selectedOptions: 99, expectedState: 'indeterminate' }, + ] + + it.each(selectionStates)( + 'should render with $expectedState state when totalOptions=$totalOptions and selectedOptions=$selectedOptions', + ({ totalOptions, selectedOptions }) => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - component should render without errors + const checkbox = container.querySelector('[class*="cursor-pointer"]') + expect(checkbox).toBeInTheDocument() + expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() + }, + ) + }) + + // ========================================== + // Layout Structure Testing + // ========================================== + describe('Layout', () => { + // Tests for correct layout structure + it('should have correct container structure', () => { + // Arrange & Act + const { container } = render() + + // Assert + const mainContainer = container.querySelector('.flex.items-center.gap-x-2.overflow-hidden') + expect(mainContainer).toBeInTheDocument() + }) + + it('should have correct button container structure', () => { + // Arrange & Act + const { container } = render() + + // Assert - buttons should be in a flex container + const buttonContainer = container.querySelector('.flex.grow.items-center.justify-end.gap-x-2') + expect(buttonContainer).toBeInTheDocument() + }) + + it('should position select all section before buttons when showSelect is true', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - select all section should exist + const selectAllSection = container.querySelector('.flex.shrink-0.items-center') + expect(selectAllSection).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx new file mode 100644 index 0000000000..a2d2980185 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx @@ -0,0 +1,461 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import ChunkPreview from './chunk-preview' +import { ChunkingMode } from '@/models/datasets' +import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets' +import type { NotionPage } from '@/models/common' +import type { OnlineDriveFile } from '@/models/pipeline' +import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' + +// Uses __mocks__/react-i18next.ts automatically + +// Mock dataset-detail context - needs mock to control return values +const mockDocForm = jest.fn() +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (_selector: (s: { dataset: { doc_form: ChunkingMode } }) => ChunkingMode) => { + return mockDocForm() + }, +})) + +// Mock document picker - needs mock for simplified interaction testing +jest.mock('../../../common/document-picker/preview-document-picker', () => ({ + __esModule: true, + default: ({ files, onChange, value }: { + files: Array<{ id: string; name: string; extension: string }> + onChange: (selected: { id: string; name: string; extension: string }) => void + value: { id: string; name: string; extension: string } + }) => ( +
+ {value?.name || 'No selection'} + +
+ ), +})) + +// Test data factories +const createMockLocalFile = (overrides?: Partial): CustomFile => ({ + id: 'file-1', + name: 'test-file.pdf', + size: 1024, + type: 'application/pdf', + extension: 'pdf', + lastModified: Date.now(), + webkitRelativePath: '', + arrayBuffer: jest.fn() as () => Promise, + bytes: jest.fn() as () => Promise, + slice: jest.fn() as (start?: number, end?: number, contentType?: string) => Blob, + stream: jest.fn() as () => ReadableStream, + text: jest.fn() as () => Promise, + ...overrides, +} as CustomFile) + +const createMockNotionPage = (overrides?: Partial): NotionPage => ({ + page_id: 'page-1', + page_name: 'Test Page', + workspace_id: 'workspace-1', + type: 'page', + page_icon: null, + parent_id: 'parent-1', + is_bound: true, + ...overrides, +}) + +const createMockCrawlResult = (overrides?: Partial): CrawlResultItem => ({ + title: 'Test Website', + markdown: 'Test content', + description: 'Test description', + source_url: 'https://example.com', + ...overrides, +}) + +const createMockOnlineDriveFile = (overrides?: Partial): OnlineDriveFile => ({ + id: 'drive-file-1', + name: 'test-drive-file.docx', + size: 2048, + type: OnlineDriveFileType.file, + ...overrides, +}) + +const createMockEstimateData = (overrides?: Partial): FileIndexingEstimateResponse => ({ + total_nodes: 5, + tokens: 1000, + total_price: 0.01, + currency: 'USD', + total_segments: 10, + preview: [ + { content: 'Chunk content 1', child_chunks: ['child 1', 'child 2'] }, + { content: 'Chunk content 2', child_chunks: ['child 3'] }, + ], + qa_preview: [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + ], + ...overrides, +}) + +const defaultProps = { + dataSourceType: DatasourceType.localFile, + localFiles: [createMockLocalFile()], + onlineDocuments: [createMockNotionPage()], + websitePages: [createMockCrawlResult()], + onlineDriveFiles: [createMockOnlineDriveFile()], + isIdle: false, + isPending: false, + estimateData: undefined, + onPreview: jest.fn(), + handlePreviewFileChange: jest.fn(), + handlePreviewOnlineDocumentChange: jest.fn(), + handlePreviewWebsitePageChange: jest.fn(), + handlePreviewOnlineDriveFileChange: jest.fn(), +} + +describe('ChunkPreview', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDocForm.mockReturnValue(ChunkingMode.text) + }) + + describe('Rendering', () => { + it('should render the component with preview container', () => { + render() + + // i18n mock returns key by default + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + + it('should render document picker for local files', () => { + render() + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + + it('should render document picker for online documents', () => { + render() + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + + it('should render document picker for website pages', () => { + render() + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + + it('should render document picker for online drive files', () => { + render() + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + + it('should render badge with chunk count for non-QA mode', () => { + const estimateData = createMockEstimateData({ total_segments: 15 }) + mockDocForm.mockReturnValue(ChunkingMode.text) + + render() + + // Badge shows chunk count via i18n key with count option + expect(screen.getByText(/previewChunkCount.*15/)).toBeInTheDocument() + }) + + it('should not render badge for QA mode', () => { + mockDocForm.mockReturnValue(ChunkingMode.qa) + const estimateData = createMockEstimateData() + + render() + + // No badge with total_segments + expect(screen.queryByText(/10/)).not.toBeInTheDocument() + }) + }) + + describe('Idle State', () => { + it('should render idle state with preview tip and button', () => { + render() + + // i18n mock returns keys + expect(screen.getByText('datasetCreation.stepTwo.previewChunkTip')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.previewChunks')).toBeInTheDocument() + }) + + it('should call onPreview when preview button is clicked', () => { + const onPreview = jest.fn() + + render() + + const button = screen.getByRole('button', { name: /previewChunks/i }) + fireEvent.click(button) + expect(onPreview).toHaveBeenCalledTimes(1) + }) + }) + + describe('Loading State', () => { + it('should render skeleton loading when isPending is true', () => { + render() + + // Skeleton loading renders multiple skeleton containers + expect(document.querySelector('.space-y-6')).toBeInTheDocument() + }) + + it('should not render preview content when loading', () => { + const estimateData = createMockEstimateData() + + render() + + expect(screen.queryByText('Chunk content 1')).not.toBeInTheDocument() + }) + }) + + describe('QA Mode Preview', () => { + it('should render QA preview chunks when doc_form is qa', () => { + mockDocForm.mockReturnValue(ChunkingMode.qa) + const estimateData = createMockEstimateData({ + qa_preview: [ + { question: 'Question 1?', answer: 'Answer 1' }, + { question: 'Question 2?', answer: 'Answer 2' }, + ], + }) + + render() + + expect(screen.getByText('Question 1?')).toBeInTheDocument() + expect(screen.getByText('Answer 1')).toBeInTheDocument() + expect(screen.getByText('Question 2?')).toBeInTheDocument() + expect(screen.getByText('Answer 2')).toBeInTheDocument() + }) + }) + + describe('Text Mode Preview', () => { + it('should render text preview chunks when doc_form is text', () => { + mockDocForm.mockReturnValue(ChunkingMode.text) + const estimateData = createMockEstimateData({ + preview: [ + { content: 'Text chunk 1', child_chunks: [] }, + { content: 'Text chunk 2', child_chunks: [] }, + ], + }) + + render() + + expect(screen.getByText('Text chunk 1')).toBeInTheDocument() + expect(screen.getByText('Text chunk 2')).toBeInTheDocument() + }) + }) + + describe('Parent-Child Mode Preview', () => { + it('should render parent-child preview chunks', () => { + mockDocForm.mockReturnValue(ChunkingMode.parentChild) + const estimateData = createMockEstimateData({ + preview: [ + { content: 'Parent chunk 1', child_chunks: ['Child 1', 'Child 2'] }, + ], + }) + + render() + + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + }) + }) + + describe('Document Selection', () => { + it('should handle local file selection change', () => { + const handlePreviewFileChange = jest.fn() + const localFiles = [ + createMockLocalFile({ id: 'file-1', name: 'file1.pdf' }), + createMockLocalFile({ id: 'file-2', name: 'file2.pdf' }), + ] + + render( + , + ) + + const select = screen.getByTestId('picker-select') + fireEvent.change(select, { target: { value: 'file-2' } }) + + expect(handlePreviewFileChange).toHaveBeenCalled() + }) + + it('should handle online document selection change', () => { + const handlePreviewOnlineDocumentChange = jest.fn() + const onlineDocuments = [ + createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + + render( + , + ) + + const select = screen.getByTestId('picker-select') + fireEvent.change(select, { target: { value: 'page-2' } }) + + expect(handlePreviewOnlineDocumentChange).toHaveBeenCalled() + }) + + it('should handle website page selection change', () => { + const handlePreviewWebsitePageChange = jest.fn() + const websitePages = [ + createMockCrawlResult({ source_url: 'https://example1.com', title: 'Site 1' }), + createMockCrawlResult({ source_url: 'https://example2.com', title: 'Site 2' }), + ] + + render( + , + ) + + const select = screen.getByTestId('picker-select') + fireEvent.change(select, { target: { value: 'https://example2.com' } }) + + expect(handlePreviewWebsitePageChange).toHaveBeenCalled() + }) + + it('should handle online drive file selection change', () => { + const handlePreviewOnlineDriveFileChange = jest.fn() + const onlineDriveFiles = [ + createMockOnlineDriveFile({ id: 'drive-1', name: 'file1.docx' }), + createMockOnlineDriveFile({ id: 'drive-2', name: 'file2.docx' }), + ] + + render( + , + ) + + const select = screen.getByTestId('picker-select') + fireEvent.change(select, { target: { value: 'drive-2' } }) + + expect(handlePreviewOnlineDriveFileChange).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty estimate data', () => { + mockDocForm.mockReturnValue(ChunkingMode.text) + + render() + + expect(screen.queryByText('Chunk content')).not.toBeInTheDocument() + }) + + it('should handle empty preview array', () => { + mockDocForm.mockReturnValue(ChunkingMode.text) + const estimateData = createMockEstimateData({ preview: [] }) + + render() + + expect(screen.queryByText('Chunk content')).not.toBeInTheDocument() + }) + + it('should handle empty qa_preview array', () => { + mockDocForm.mockReturnValue(ChunkingMode.qa) + const estimateData = createMockEstimateData({ qa_preview: [] }) + + render() + + expect(screen.queryByText('Q1')).not.toBeInTheDocument() + }) + + it('should handle empty child_chunks in parent-child mode', () => { + mockDocForm.mockReturnValue(ChunkingMode.parentChild) + const estimateData = createMockEstimateData({ + preview: [{ content: 'Parent', child_chunks: [] }], + }) + + render() + + expect(screen.queryByText('Child')).not.toBeInTheDocument() + }) + + it('should handle badge showing 0 chunks', () => { + mockDocForm.mockReturnValue(ChunkingMode.text) + const estimateData = createMockEstimateData({ total_segments: 0 }) + + render() + + // Badge with 0 + expect(screen.getByText(/0/)).toBeInTheDocument() + }) + + it('should handle undefined online document properties', () => { + const onlineDocuments = [createMockNotionPage({ page_id: '', page_name: '' })] + + render( + , + ) + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + + it('should handle undefined website page properties', () => { + const websitePages = [createMockCrawlResult({ source_url: '', title: '' })] + + render( + , + ) + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + + it('should handle undefined online drive file properties', () => { + const onlineDriveFiles = [createMockOnlineDriveFile({ id: '', name: '' })] + + render( + , + ) + + expect(screen.getByTestId('document-picker')).toBeInTheDocument() + }) + }) + + describe('Component Memoization', () => { + it('should be exported as a memoized component', () => { + // ChunkPreview is wrapped with React.memo + // We verify this by checking the component type + expect(typeof ChunkPreview).toBe('object') + expect(ChunkPreview.$$typeof?.toString()).toBe('Symbol(react.memo)') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx new file mode 100644 index 0000000000..8cb6ac489c --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx @@ -0,0 +1,320 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import FilePreview from './file-preview' +import type { CustomFile as File } from '@/models/datasets' + +// Uses __mocks__/react-i18next.ts automatically + +// Mock useFilePreview hook - needs to be mocked to control return values +const mockUseFilePreview = jest.fn() +jest.mock('@/service/use-common', () => ({ + useFilePreview: (fileID: string) => mockUseFilePreview(fileID), +})) + +// Test data factory +const createMockFile = (overrides?: Partial): File => ({ + id: 'file-123', + name: 'test-document.pdf', + size: 2048, + type: 'application/pdf', + extension: 'pdf', + lastModified: Date.now(), + webkitRelativePath: '', + arrayBuffer: jest.fn() as () => Promise, + bytes: jest.fn() as () => Promise, + slice: jest.fn() as (start?: number, end?: number, contentType?: string) => Blob, + stream: jest.fn() as () => ReadableStream, + text: jest.fn() as () => Promise, + ...overrides, +} as File) + +const createMockFilePreviewData = (content: string = 'This is the file content') => ({ + content, +}) + +const defaultProps = { + file: createMockFile(), + hidePreview: jest.fn(), +} + +describe('FilePreview', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseFilePreview.mockReturnValue({ + data: undefined, + isFetching: false, + }) + }) + + describe('Rendering', () => { + it('should render the component with file information', () => { + render() + + // i18n mock returns key by default + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + expect(screen.getByText('test-document.pdf')).toBeInTheDocument() + }) + + it('should display file extension in uppercase via CSS class', () => { + render() + + // The extension is displayed in the info section (as uppercase via CSS class) + const extensionElement = screen.getByText('pdf') + expect(extensionElement).toBeInTheDocument() + expect(extensionElement).toHaveClass('uppercase') + }) + + it('should display formatted file size', () => { + render() + + // Real formatFileSize: 2048 bytes => "2.00 KB" + expect(screen.getByText('2.00 KB')).toBeInTheDocument() + }) + + it('should render close button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should call useFilePreview with correct fileID', () => { + const file = createMockFile({ id: 'specific-file-id' }) + + render() + + expect(mockUseFilePreview).toHaveBeenCalledWith('specific-file-id') + }) + }) + + describe('File Name Processing', () => { + it('should extract file name without extension', () => { + const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' }) + + render() + + // The displayed text is `${fileName}.${extension}`, where fileName is name without ext + // my-document.pdf -> fileName = 'my-document', displayed as 'my-document.pdf' + expect(screen.getByText('my-document.pdf')).toBeInTheDocument() + }) + + it('should handle file name with multiple dots', () => { + const file = createMockFile({ name: 'my.file.name.pdf', extension: 'pdf' }) + + render() + + // fileName = arr.slice(0, -1).join() = 'my,file,name', then displayed as 'my,file,name.pdf' + expect(screen.getByText('my,file,name.pdf')).toBeInTheDocument() + }) + + it('should handle empty file name', () => { + const file = createMockFile({ name: '', extension: '' }) + + render() + + // fileName = '', displayed as '.' + expect(screen.getByText('.')).toBeInTheDocument() + }) + + it('should handle file without extension in name', () => { + const file = createMockFile({ name: 'noextension', extension: '' }) + + render() + + // fileName = '' (slice returns empty for single element array), displayed as '.' + expect(screen.getByText('.')).toBeInTheDocument() + }) + }) + + describe('Loading State', () => { + it('should render loading component when fetching', () => { + mockUseFilePreview.mockReturnValue({ + data: undefined, + isFetching: true, + }) + + render() + + // Loading component renders skeleton + expect(document.querySelector('.overflow-hidden')).toBeInTheDocument() + }) + + it('should not render content when loading', () => { + mockUseFilePreview.mockReturnValue({ + data: createMockFilePreviewData('Some content'), + isFetching: true, + }) + + render() + + expect(screen.queryByText('Some content')).not.toBeInTheDocument() + }) + }) + + describe('Content Display', () => { + it('should render file content when loaded', () => { + mockUseFilePreview.mockReturnValue({ + data: createMockFilePreviewData('This is the file content'), + isFetching: false, + }) + + render() + + expect(screen.getByText('This is the file content')).toBeInTheDocument() + }) + + it('should display character count when data is available', () => { + mockUseFilePreview.mockReturnValue({ + data: createMockFilePreviewData('Hello'), // 5 characters + isFetching: false, + }) + + render() + + // Real formatNumberAbbreviated returns "5" for numbers < 1000 + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + + it('should format large character counts', () => { + const longContent = 'a'.repeat(2500) + mockUseFilePreview.mockReturnValue({ + data: createMockFilePreviewData(longContent), + isFetching: false, + }) + + render() + + // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" + expect(screen.getByText(/2\.5k/)).toBeInTheDocument() + }) + + it('should not display character count when data is not available', () => { + mockUseFilePreview.mockReturnValue({ + data: undefined, + isFetching: false, + }) + + render() + + // No character text shown + expect(screen.queryByText(/datasetPipeline\.addDocuments\.characters/)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', () => { + const hidePreview = jest.fn() + + render() + + const closeButton = screen.getByRole('button') + fireEvent.click(closeButton) + + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + }) + + describe('File Size Formatting', () => { + it('should format small file sizes in bytes', () => { + const file = createMockFile({ size: 500 }) + + render() + + // Real formatFileSize: 500 => "500.00 bytes" + expect(screen.getByText('500.00 bytes')).toBeInTheDocument() + }) + + it('should format kilobyte file sizes', () => { + const file = createMockFile({ size: 5120 }) + + render() + + // Real formatFileSize: 5120 => "5.00 KB" + expect(screen.getByText('5.00 KB')).toBeInTheDocument() + }) + + it('should format megabyte file sizes', () => { + const file = createMockFile({ size: 2097152 }) + + render() + + // Real formatFileSize: 2097152 => "2.00 MB" + expect(screen.getByText('2.00 MB')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined file id', () => { + const file = createMockFile({ id: undefined }) + + render() + + expect(mockUseFilePreview).toHaveBeenCalledWith('') + }) + + it('should handle empty extension', () => { + const file = createMockFile({ extension: undefined }) + + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle zero file size', () => { + const file = createMockFile({ size: 0 }) + + render() + + // Real formatFileSize returns 0 for falsy values + // The component still renders + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle very long file content', () => { + const veryLongContent = 'a'.repeat(1000000) + mockUseFilePreview.mockReturnValue({ + data: createMockFilePreviewData(veryLongContent), + isFetching: false, + }) + + render() + + // Real formatNumberAbbreviated: 1000000 => "1M" + expect(screen.getByText(/1M/)).toBeInTheDocument() + }) + + it('should handle empty content', () => { + mockUseFilePreview.mockReturnValue({ + data: createMockFilePreviewData(''), + isFetching: false, + }) + + render() + + // Real formatNumberAbbreviated: 0 => "0" + // Find the element that contains character count info + expect(screen.getByText(/0 datasetPipeline/)).toBeInTheDocument() + }) + }) + + describe('useMemo for fileName', () => { + it('should extract file name when file exists', () => { + // When file exists, it should extract the name without extension + const file = createMockFile({ name: 'document.txt', extension: 'txt' }) + + render() + + expect(screen.getByText('document.txt')).toBeInTheDocument() + }) + + it('should memoize fileName based on file prop', () => { + const file = createMockFile({ name: 'test.pdf', extension: 'pdf' }) + + const { rerender } = render() + + // Same file should produce same result + rerender() + + expect(screen.getByText('test.pdf')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx new file mode 100644 index 0000000000..652d6d573f --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx @@ -0,0 +1,359 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent } from '@testing-library/react' +import React from 'react' +import OnlineDocumentPreview from './online-document-preview' +import type { NotionPage } from '@/models/common' +import Toast from '@/app/components/base/toast' + +// Uses __mocks__/react-i18next.ts automatically + +// Spy on Toast.notify +const toastNotifySpy = jest.spyOn(Toast, 'notify') + +// Mock dataset-detail context - needs mock to control return values +const mockPipelineId = jest.fn() +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (_selector: (s: { dataset: { pipeline_id: string } }) => string) => { + return mockPipelineId() + }, +})) + +// Mock usePreviewOnlineDocument hook - needs mock to control mutation behavior +const mockMutateAsync = jest.fn() +const mockUsePreviewOnlineDocument = jest.fn() +jest.mock('@/service/use-pipeline', () => ({ + usePreviewOnlineDocument: () => mockUsePreviewOnlineDocument(), +})) + +// Mock data source store - needs mock to control store state +const mockCurrentCredentialId = 'credential-123' +const mockGetState = jest.fn(() => ({ + currentCredentialId: mockCurrentCredentialId, +})) +jest.mock('../data-source/store', () => ({ + useDataSourceStore: () => ({ + getState: mockGetState, + }), +})) + +// Test data factory +const createMockNotionPage = (overrides?: Partial): NotionPage => ({ + page_id: 'page-123', + page_name: 'Test Notion Page', + workspace_id: 'workspace-456', + type: 'page', + page_icon: null, + parent_id: 'parent-789', + is_bound: true, + ...overrides, +}) + +const defaultProps = { + currentPage: createMockNotionPage(), + datasourceNodeId: 'datasource-node-123', + hidePreview: jest.fn(), +} + +describe('OnlineDocumentPreview', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPipelineId.mockReturnValue('pipeline-123') + mockUsePreviewOnlineDocument.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + }) + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onSuccess({ content: 'Test content' }) + return Promise.resolve({ content: 'Test content' }) + }) + }) + + describe('Rendering', () => { + it('should render the component with page information', () => { + render() + + // i18n mock returns key by default + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + expect(screen.getByText('Test Notion Page')).toBeInTheDocument() + }) + + it('should display page type', () => { + const currentPage = createMockNotionPage({ type: 'database' }) + + render() + + expect(screen.getByText('database')).toBeInTheDocument() + }) + + it('should render close button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('Data Fetching', () => { + it('should call mutateAsync with correct parameters on mount', async () => { + const currentPage = createMockNotionPage({ + workspace_id: 'ws-123', + page_id: 'pg-456', + type: 'page', + }) + + render( + , + ) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + { + workspaceID: 'ws-123', + pageID: 'pg-456', + pageType: 'page', + pipelineId: 'pipeline-123', + datasourceNodeId: 'node-789', + credentialId: mockCurrentCredentialId, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) + }) + }) + + it('should fetch data again when page_id changes', async () => { + const currentPage1 = createMockNotionPage({ page_id: 'page-1' }) + const currentPage2 = createMockNotionPage({ page_id: 'page-2' }) + + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + }) + + rerender() + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(2) + }) + }) + + it('should handle empty pipelineId', async () => { + mockPipelineId.mockReturnValue(undefined) + + render() + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + pipelineId: '', + }), + expect.anything(), + ) + }) + }) + }) + + describe('Loading State', () => { + it('should render loading component when isPending is true', () => { + mockUsePreviewOnlineDocument.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: true, + }) + + render() + + // Loading component renders skeleton + expect(document.querySelector('.overflow-hidden')).toBeInTheDocument() + }) + + it('should not render markdown content when loading', () => { + mockUsePreviewOnlineDocument.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: true, + }) + + render() + + // Content area should not be present + expect(screen.queryByText('Test content')).not.toBeInTheDocument() + }) + }) + + describe('Content Display', () => { + it('should render markdown content when loaded', async () => { + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onSuccess({ content: 'Markdown content here' }) + return Promise.resolve({ content: 'Markdown content here' }) + }) + + render() + + await waitFor(() => { + // Markdown component renders the content + const contentArea = document.querySelector('.overflow-hidden.px-6.py-5') + expect(contentArea).toBeInTheDocument() + }) + }) + + it('should display character count', async () => { + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onSuccess({ content: 'Hello' }) // 5 characters + return Promise.resolve({ content: 'Hello' }) + }) + + render() + + await waitFor(() => { + // Real formatNumberAbbreviated returns "5" for numbers < 1000 + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + }) + + it('should format large character counts', async () => { + const longContent = 'a'.repeat(2500) + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onSuccess({ content: longContent }) + return Promise.resolve({ content: longContent }) + }) + + render() + + await waitFor(() => { + // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" + expect(screen.getByText(/2\.5k/)).toBeInTheDocument() + }) + }) + + it('should show character count based on fetched content', async () => { + // When content is set via onSuccess, character count is displayed + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onSuccess({ content: 'Test content' }) // 12 characters + return Promise.resolve({ content: 'Test content' }) + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/12/)).toBeInTheDocument() + }) + }) + }) + + describe('Error Handling', () => { + it('should show toast notification on error', async () => { + const errorMessage = 'Failed to fetch document' + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onError(new Error(errorMessage)) + // Return a resolved promise to avoid unhandled rejection + return Promise.resolve() + }) + + render() + + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + type: 'error', + message: errorMessage, + }) + }) + }) + + it('should handle network errors', async () => { + const networkError = new Error('Network Error') + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onError(networkError) + // Return a resolved promise to avoid unhandled rejection + return Promise.resolve() + }) + + render() + + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + type: 'error', + message: 'Network Error', + }) + }) + }) + }) + + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', () => { + const hidePreview = jest.fn() + + render() + + // Find the close button in the header area (not toast buttons) + const headerArea = document.querySelector('.flex.gap-x-2.border-b') + const closeButton = headerArea?.querySelector('button') + expect(closeButton).toBeInTheDocument() + fireEvent.click(closeButton!) + + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined page_name', () => { + const currentPage = createMockNotionPage({ page_name: '' }) + + render() + + // Find the close button in the header area + const headerArea = document.querySelector('.flex.gap-x-2.border-b') + const closeButton = headerArea?.querySelector('button') + expect(closeButton).toBeInTheDocument() + }) + + it('should handle different page types', () => { + const currentPage = createMockNotionPage({ type: 'database' }) + + render() + + expect(screen.getByText('database')).toBeInTheDocument() + }) + + it('should use credentialId from store', async () => { + mockGetState.mockReturnValue({ + currentCredentialId: 'custom-credential', + }) + + render() + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'custom-credential', + }), + expect.anything(), + ) + }) + }) + + it('should not render markdown content when content is empty and not pending', async () => { + mockMutateAsync.mockImplementation((params, callbacks) => { + callbacks.onSuccess({ content: '' }) + return Promise.resolve({ content: '' }) + }) + mockUsePreviewOnlineDocument.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + }) + + render() + + // Content is empty, markdown area should still render but be empty + await waitFor(() => { + expect(screen.queryByText('Test content')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx new file mode 100644 index 0000000000..97343e75ee --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx @@ -0,0 +1,256 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import WebsitePreview from './web-preview' +import type { CrawlResultItem } from '@/models/datasets' + +// Uses __mocks__/react-i18next.ts automatically + +// Test data factory +const createMockCrawlResult = (overrides?: Partial): CrawlResultItem => ({ + title: 'Test Website Title', + markdown: 'This is the **markdown** content of the website.', + description: 'Test description', + source_url: 'https://example.com/page', + ...overrides, +}) + +const defaultProps = { + currentWebsite: createMockCrawlResult(), + hidePreview: jest.fn(), +} + +describe('WebsitePreview', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the component with website information', () => { + render() + + // i18n mock returns key by default + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + expect(screen.getByText('Test Website Title')).toBeInTheDocument() + }) + + it('should display the source URL', () => { + render() + + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should render close button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render the markdown content', () => { + render() + + expect(screen.getByText('This is the **markdown** content of the website.')).toBeInTheDocument() + }) + }) + + describe('Character Count', () => { + it('should display character count for small content', () => { + const currentWebsite = createMockCrawlResult({ markdown: 'Hello' }) // 5 characters + + render() + + // Real formatNumberAbbreviated returns "5" for numbers < 1000 + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + + it('should format character count in thousands', () => { + const longContent = 'a'.repeat(2500) + const currentWebsite = createMockCrawlResult({ markdown: longContent }) + + render() + + // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" + expect(screen.getByText(/2\.5k/)).toBeInTheDocument() + }) + + it('should format character count in millions', () => { + const veryLongContent = 'a'.repeat(1500000) + const currentWebsite = createMockCrawlResult({ markdown: veryLongContent }) + + render() + + expect(screen.getByText(/1\.5M/)).toBeInTheDocument() + }) + + it('should show 0 characters for empty markdown', () => { + const currentWebsite = createMockCrawlResult({ markdown: '' }) + + render() + + expect(screen.getByText(/0/)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', () => { + const hidePreview = jest.fn() + + render() + + const closeButton = screen.getByRole('button') + fireEvent.click(closeButton) + + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + }) + + describe('URL Display', () => { + it('should display long URLs', () => { + const longUrl = 'https://example.com/very/long/path/to/page/with/many/segments' + const currentWebsite = createMockCrawlResult({ source_url: longUrl }) + + render() + + const urlElement = screen.getByTitle(longUrl) + expect(urlElement).toBeInTheDocument() + expect(urlElement).toHaveTextContent(longUrl) + }) + + it('should display URL with title attribute', () => { + const currentWebsite = createMockCrawlResult({ source_url: 'https://test.com' }) + + render() + + expect(screen.getByTitle('https://test.com')).toBeInTheDocument() + }) + }) + + describe('Content Display', () => { + it('should display the markdown content in content area', () => { + const currentWebsite = createMockCrawlResult({ + markdown: 'Content with **bold** and *italic* text.', + }) + + render() + + expect(screen.getByText('Content with **bold** and *italic* text.')).toBeInTheDocument() + }) + + it('should handle multiline content', () => { + const multilineContent = 'Line 1\nLine 2\nLine 3' + const currentWebsite = createMockCrawlResult({ markdown: multilineContent }) + + render() + + // Multiline content is rendered as-is + expect(screen.getByText((content) => { + return content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3') + })).toBeInTheDocument() + }) + + it('should handle special characters in content', () => { + const specialContent = ' & < > " \'' + const currentWebsite = createMockCrawlResult({ markdown: specialContent }) + + render() + + expect(screen.getByText(specialContent)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty title', () => { + const currentWebsite = createMockCrawlResult({ title: '' }) + + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle empty source URL', () => { + const currentWebsite = createMockCrawlResult({ source_url: '' }) + + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle very long title', () => { + const longTitle = 'A'.repeat(500) + const currentWebsite = createMockCrawlResult({ title: longTitle }) + + render() + + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should handle unicode characters in content', () => { + const unicodeContent = 'ไฝ ๅฅฝไธ–็•Œ ๐ŸŒ ู…ุฑุญุจุง ใ“ใ‚“ใซใกใฏ' + const currentWebsite = createMockCrawlResult({ markdown: unicodeContent }) + + render() + + expect(screen.getByText(unicodeContent)).toBeInTheDocument() + }) + + it('should handle URL with query parameters', () => { + const urlWithParams = 'https://example.com/page?query=test¶m=value' + const currentWebsite = createMockCrawlResult({ source_url: urlWithParams }) + + render() + + expect(screen.getByTitle(urlWithParams)).toBeInTheDocument() + }) + + it('should handle URL with hash fragment', () => { + const urlWithHash = 'https://example.com/page#section-1' + const currentWebsite = createMockCrawlResult({ source_url: urlWithHash }) + + render() + + expect(screen.getByTitle(urlWithHash)).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should apply container styles', () => { + const { container } = render() + + const mainContainer = container.firstChild as HTMLElement + expect(mainContainer).toHaveClass('flex', 'h-full', 'w-full', 'flex-col') + }) + }) + + describe('Multiple Renders', () => { + it('should update when currentWebsite changes', () => { + const website1 = createMockCrawlResult({ title: 'Website 1', markdown: 'Content 1' }) + const website2 = createMockCrawlResult({ title: 'Website 2', markdown: 'Content 2' }) + + const { rerender } = render() + + expect(screen.getByText('Website 1')).toBeInTheDocument() + expect(screen.getByText('Content 1')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('Website 2')).toBeInTheDocument() + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) + + it('should call new hidePreview when prop changes', () => { + const hidePreview1 = jest.fn() + const hidePreview2 = jest.fn() + + const { rerender } = render() + + const closeButton = screen.getByRole('button') + fireEvent.click(closeButton) + expect(hidePreview1).toHaveBeenCalledTimes(1) + + rerender() + + fireEvent.click(closeButton) + expect(hidePreview2).toHaveBeenCalledTimes(1) + expect(hidePreview1).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx new file mode 100644 index 0000000000..c92ce491fb --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx @@ -0,0 +1,861 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import Actions from './actions' +import Header from './header' +import Form from './form' +import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' +import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' +import { z } from 'zod' +import Toast from '@/app/components/base/toast' + +// ========================================== +// Spy on Toast.notify for validation tests +// ========================================== +const toastNotifySpy = jest.spyOn(Toast, 'notify') + +// ========================================== +// Test Data Factory Functions +// ========================================== + +/** + * Creates mock configuration for testing + */ +const createMockConfiguration = (overrides: Partial = {}): BaseConfiguration => ({ + type: BaseFieldType.textInput, + variable: 'testVariable', + label: 'Test Label', + required: false, + maxLength: undefined, + options: undefined, + showConditions: [], + placeholder: 'Enter value', + tooltip: '', + ...overrides, +}) + +/** + * Creates a valid Zod schema for testing + */ +const createMockSchema = () => { + return z.object({ + field1: z.string().optional(), + }) +} + +/** + * Creates a schema that always fails validation + */ +const createFailingSchema = () => { + return { + safeParse: () => ({ + success: false, + error: { + issues: [{ path: ['field1'], message: 'is required' }], + }, + }), + } as unknown as z.ZodSchema +} + +// ========================================== +// Actions Component Tests +// ========================================== +describe('Actions', () => { + const defaultActionsProps = { + onBack: jest.fn(), + onProcess: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() + }) + + it('should render back button with arrow icon', () => { + // Arrange & Act + render() + + // Assert + const backButton = screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }) + expect(backButton).toBeInTheDocument() + expect(backButton.querySelector('svg')).toBeInTheDocument() + }) + + it('should render process button', () => { + // Arrange & Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).toBeInTheDocument() + }) + + it('should have correct container layout', () => { + // Arrange & Act + const { container } = render() + + // Assert + const mainContainer = container.querySelector('.flex.items-center.justify-between') + expect(mainContainer).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('runDisabled prop', () => { + it('should not disable process button when runDisabled is false', () => { + // Arrange & Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).not.toBeDisabled() + }) + + it('should disable process button when runDisabled is true', () => { + // Arrange & Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).toBeDisabled() + }) + + it('should not disable process button when runDisabled is undefined', () => { + // Arrange & Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).not.toBeDisabled() + }) + }) + }) + + // ========================================== + // User Interactions Testing + // ========================================== + describe('User Interactions', () => { + it('should call onBack when back button is clicked', () => { + // Arrange + const onBack = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) + + // Assert + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('should call onProcess when process button is clicked', () => { + // Arrange + const onProcess = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + + // Assert + expect(onProcess).toHaveBeenCalledTimes(1) + }) + + it('should not call onProcess when process button is disabled and clicked', () => { + // Arrange + const onProcess = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + + // Assert + expect(onProcess).not.toHaveBeenCalled() + }) + }) + + // ========================================== + // Component Memoization Testing + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(Actions.$$typeof).toBe(Symbol.for('react.memo')) + }) + }) +}) + +// ========================================== +// Header Component Tests +// ========================================== +describe('Header', () => { + const defaultHeaderProps = { + onReset: jest.fn(), + resetDisabled: false, + previewDisabled: false, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render(
) + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should render reset button', () => { + // Arrange & Act + render(
) + + // Assert + expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() + }) + + it('should render preview button with icon', () => { + // Arrange & Act + render(
) + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).toBeInTheDocument() + expect(previewButton.querySelector('svg')).toBeInTheDocument() + }) + + it('should render title with correct text', () => { + // Arrange & Act + render(
) + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should have correct container layout', () => { + // Arrange & Act + const { container } = render(
) + + // Assert + const mainContainer = container.querySelector('.flex.items-center.gap-x-1') + expect(mainContainer).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('resetDisabled prop', () => { + it('should not disable reset button when resetDisabled is false', () => { + // Arrange & Act + render(
) + + // Assert + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + expect(resetButton).not.toBeDisabled() + }) + + it('should disable reset button when resetDisabled is true', () => { + // Arrange & Act + render(
) + + // Assert + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + expect(resetButton).toBeDisabled() + }) + }) + + describe('previewDisabled prop', () => { + it('should not disable preview button when previewDisabled is false', () => { + // Arrange & Act + render(
) + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).not.toBeDisabled() + }) + + it('should disable preview button when previewDisabled is true', () => { + // Arrange & Act + render(
) + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).toBeDisabled() + }) + }) + + it('should handle onPreview being undefined', () => { + // Arrange & Act + render(
) + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).toBeInTheDocument() + // Click should not throw + let didThrow = false + try { + fireEvent.click(previewButton) + } + catch { + didThrow = true + } + expect(didThrow).toBe(false) + }) + }) + + // ========================================== + // User Interactions Testing + // ========================================== + describe('User Interactions', () => { + it('should call onReset when reset button is clicked', () => { + // Arrange + const onReset = jest.fn() + render(
) + + // Act + fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) + + // Assert + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('should not call onReset when reset button is disabled and clicked', () => { + // Arrange + const onReset = jest.fn() + render(
) + + // Act + fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) + + // Assert + expect(onReset).not.toHaveBeenCalled() + }) + + it('should call onPreview when preview button is clicked', () => { + // Arrange + const onPreview = jest.fn() + render(
) + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + + // Assert + expect(onPreview).toHaveBeenCalledTimes(1) + }) + + it('should not call onPreview when preview button is disabled and clicked', () => { + // Arrange + const onPreview = jest.fn() + render(
) + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + + // Assert + expect(onPreview).not.toHaveBeenCalled() + }) + }) + + // ========================================== + // Component Memoization Testing + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(Header.$$typeof).toBe(Symbol.for('react.memo')) + }) + }) + + // ========================================== + // Edge Cases Testing + // ========================================== + describe('Edge Cases', () => { + it('should handle both buttons disabled', () => { + // Arrange & Act + render(
) + + // Assert + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(resetButton).toBeDisabled() + expect(previewButton).toBeDisabled() + }) + + it('should handle both buttons enabled', () => { + // Arrange & Act + render(
) + + // Assert + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(resetButton).not.toBeDisabled() + expect(previewButton).not.toBeDisabled() + }) + }) +}) + +// ========================================== +// Form Component Tests +// ========================================== +describe('Form', () => { + const defaultFormProps = { + initialData: { field1: '' }, + configurations: [] as BaseConfiguration[], + schema: createMockSchema(), + onSubmit: jest.fn(), + onPreview: jest.fn(), + ref: { current: null } as React.RefObject, + isRunning: false, + } + + beforeEach(() => { + jest.clearAllMocks() + toastNotifySpy.mockClear() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render(
) + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should render form element', () => { + // Arrange & Act + const { container } = render() + + // Assert + const form = container.querySelector('form') + expect(form).toBeInTheDocument() + }) + + it('should render Header component', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })).toBeInTheDocument() + }) + + it('should have correct form structure', () => { + // Arrange & Act + const { container } = render() + + // Assert + const form = container.querySelector('form.flex.w-full.flex-col') + expect(form).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('isRunning prop', () => { + it('should disable preview button when isRunning is true', () => { + // Arrange & Act + render() + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).toBeDisabled() + }) + + it('should not disable preview button when isRunning is false', () => { + // Arrange & Act + render() + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).not.toBeDisabled() + }) + }) + + describe('configurations prop', () => { + it('should render empty when configurations is empty', () => { + // Arrange & Act + const { container } = render() + + // Assert - the fields container should have no field children + const fieldsContainer = container.querySelector('.flex.flex-col.gap-3') + expect(fieldsContainer?.children.length).toBe(0) + }) + + it('should render all configurations', () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'var1', label: 'Variable 1' }), + createMockConfiguration({ variable: 'var2', label: 'Variable 2' }), + createMockConfiguration({ variable: 'var3', label: 'Variable 3' }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Variable 1')).toBeInTheDocument() + expect(screen.getByText('Variable 2')).toBeInTheDocument() + expect(screen.getByText('Variable 3')).toBeInTheDocument() + }) + }) + + it('should expose submit method via ref', () => { + // Arrange + const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> + + // Act + render() + + // Assert + expect(mockRef.current).not.toBeNull() + expect(typeof mockRef.current?.submit).toBe('function') + }) + }) + + // ========================================== + // Ref Submit Testing + // ========================================== + describe('Ref Submit', () => { + it('should call onSubmit when ref.submit() is called', async () => { + // Arrange + const onSubmit = jest.fn() + const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> + render() + + // Act - call submit via ref + mockRef.current?.submit() + + // Assert + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + }) + + it('should trigger form validation when ref.submit() is called', async () => { + // Arrange + const failingSchema = createFailingSchema() + const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> + render() + + // Act - call submit via ref + mockRef.current?.submit() + + // Assert - validation error should be shown + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + type: 'error', + message: '"field1" is required', + }) + }) + }) + }) + + // ========================================== + // User Interactions Testing + // ========================================== + describe('User Interactions', () => { + it('should call onPreview when preview button is clicked', () => { + // Arrange + const onPreview = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + + // Assert + expect(onPreview).toHaveBeenCalledTimes(1) + }) + + it('should handle form submission via form element', async () => { + // Arrange + const onSubmit = jest.fn() + const { container } = render() + const form = container.querySelector('form')! + + // Act + fireEvent.submit(form) + + // Assert + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + }) + }) + + // ========================================== + // Form State Testing + // ========================================== + describe('Form State', () => { + it('should disable reset button initially when form is not dirty', () => { + // Arrange & Act + render() + + // Assert + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + expect(resetButton).toBeDisabled() + }) + + it('should enable reset button when form becomes dirty', async () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field1', label: 'Field 1' }), + ] + + render() + + // Act - change input to make form dirty + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new value' } }) + + // Assert + await waitFor(() => { + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + expect(resetButton).not.toBeDisabled() + }) + }) + + it('should reset form to initial values when reset button is clicked', async () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field1', label: 'Field 1' }), + ] + const initialData = { field1: 'initial value' } + + render() + + // Act - change input to make form dirty + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new value' } }) + + // Wait for reset button to be enabled + await waitFor(() => { + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + expect(resetButton).not.toBeDisabled() + }) + + // Click reset button + const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) + fireEvent.click(resetButton) + + // Assert - form should be reset, button should be disabled again + await waitFor(() => { + expect(resetButton).toBeDisabled() + }) + }) + + it('should call form.reset when handleReset is triggered', async () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field1', label: 'Field 1' }), + ] + const initialData = { field1: 'original' } + + render() + + // Make form dirty + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'modified' } }) + + // Wait for dirty state + await waitFor(() => { + expect(screen.getByRole('button', { name: /common.operation.reset/i })).not.toBeDisabled() + }) + + // Act - click reset + fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) + + // Assert - input should be reset to initial value + await waitFor(() => { + expect(input).toHaveValue('original') + }) + }) + }) + + // ========================================== + // Validation Testing + // ========================================== + describe('Validation', () => { + it('should show toast notification on validation error', async () => { + // Arrange + const failingSchema = createFailingSchema() + const { container } = render() + + // Act + const form = container.querySelector('form')! + fireEvent.submit(form) + + // Assert + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + type: 'error', + message: '"field1" is required', + }) + }) + }) + + it('should not call onSubmit when validation fails', async () => { + // Arrange + const onSubmit = jest.fn() + const failingSchema = createFailingSchema() + const { container } = render() + + // Act + const form = container.querySelector('form')! + fireEvent.submit(form) + + // Assert - wait a bit and verify onSubmit was not called + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalled() + }) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should call onSubmit when validation passes', async () => { + // Arrange + const onSubmit = jest.fn() + const passingSchema = createMockSchema() + const { container } = render() + + // Act + const form = container.querySelector('form')! + fireEvent.submit(form) + + // Assert + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + }) + }) + + // ========================================== + // Edge Cases Testing + // ========================================== + describe('Edge Cases', () => { + it('should handle empty initialData', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should handle configurations with different field types', () => { + // Arrange + const configurations = [ + createMockConfiguration({ type: BaseFieldType.textInput, variable: 'text', label: 'Text Field' }), + createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'number', label: 'Number Field' }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Text Field')).toBeInTheDocument() + expect(screen.getByText('Number Field')).toBeInTheDocument() + }) + + it('should handle null ref', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + }) + + // ========================================== + // Configuration Variations Testing + // ========================================== + describe('Configuration Variations', () => { + it('should render configuration with label', () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field1', label: 'Custom Label' }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Custom Label')).toBeInTheDocument() + }) + + it('should render required configuration', () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field1', label: 'Required Field', required: true }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Required Field')).toBeInTheDocument() + }) + }) +}) + +// ========================================== +// Integration Tests (Cross-component) +// ========================================== +describe('Process Documents Components Integration', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Form with Header Integration', () => { + const defaultFormProps = { + initialData: { field1: '' }, + configurations: [] as BaseConfiguration[], + schema: createMockSchema(), + onSubmit: jest.fn(), + onPreview: jest.fn(), + ref: { current: null } as React.RefObject, + isRunning: false, + } + + it('should render Header within Form', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() + }) + + it('should pass isRunning to Header for previewDisabled', () => { + // Arrange & Act + render() + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx new file mode 100644 index 0000000000..8b132de0de --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx @@ -0,0 +1,601 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import ProcessDocuments from './index' +import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' +import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' + +// ========================================== +// Mock External Dependencies +// ========================================== + +// Mock useInputVariables hook +let mockIsFetchingParams = false +let mockParamsConfig: { variables: unknown[] } | undefined = { variables: [] } +jest.mock('./hooks', () => ({ + useInputVariables: jest.fn(() => ({ + isFetchingParams: mockIsFetchingParams, + paramsConfig: mockParamsConfig, + })), +})) + +// Mock useConfigurations hook +let mockConfigurations: BaseConfiguration[] = [] + +// Mock useInitialData hook +let mockInitialData: Record = {} +jest.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ + useInitialData: jest.fn(() => mockInitialData), + useConfigurations: jest.fn(() => mockConfigurations), +})) + +// ========================================== +// Test Data Factory Functions +// ========================================== + +/** + * Creates mock configuration for testing + */ +const createMockConfiguration = (overrides: Partial = {}): BaseConfiguration => ({ + type: BaseFieldType.textInput, + variable: 'testVariable', + label: 'Test Label', + required: false, + maxLength: undefined, + options: undefined, + showConditions: [], + placeholder: '', + tooltip: '', + ...overrides, +}) + +/** + * Creates default test props + */ +const createDefaultProps = (overrides: Partial> = {}) => ({ + dataSourceNodeId: 'test-node-id', + ref: { current: null } as React.RefObject, + isRunning: false, + onProcess: jest.fn(), + onPreview: jest.fn(), + onSubmit: jest.fn(), + onBack: jest.fn(), + ...overrides, +}) + +// ========================================== +// Test Suite +// ========================================== + +describe('ProcessDocuments', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset mock values + mockIsFetchingParams = false + mockParamsConfig = { variables: [] } + mockInitialData = {} + mockConfigurations = [] + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + // Tests basic rendering functionality + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - check for Header title from Form component + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should render Form and Actions components', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - check for elements from both components + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() + }) + + it('should render with correct container structure', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const mainContainer = container.querySelector('.flex.flex-col.gap-y-4.pt-4') + expect(mainContainer).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('dataSourceNodeId prop', () => { + it('should pass dataSourceNodeId to useInputVariables hook', () => { + // Arrange + const { useInputVariables } = require('./hooks') + const props = createDefaultProps({ dataSourceNodeId: 'custom-node-id' }) + + // Act + render() + + // Assert + expect(useInputVariables).toHaveBeenCalledWith('custom-node-id') + }) + + it('should handle empty dataSourceNodeId', () => { + // Arrange + const props = createDefaultProps({ dataSourceNodeId: '' }) + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + }) + + describe('isRunning prop', () => { + it('should disable preview button when isRunning is true', () => { + // Arrange + const props = createDefaultProps({ isRunning: true }) + + // Act + render() + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).toBeDisabled() + }) + + it('should not disable preview button when isRunning is false', () => { + // Arrange + const props = createDefaultProps({ isRunning: false }) + + // Act + render() + + // Assert + const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) + expect(previewButton).not.toBeDisabled() + }) + + it('should disable process button in Actions when isRunning is true', () => { + // Arrange + mockIsFetchingParams = false + const props = createDefaultProps({ isRunning: true }) + + // Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).toBeDisabled() + }) + }) + + describe('ref prop', () => { + it('should expose submit method via ref', () => { + // Arrange + const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> + const props = createDefaultProps({ ref: mockRef }) + + // Act + render() + + // Assert + expect(mockRef.current).not.toBeNull() + expect(typeof mockRef.current?.submit).toBe('function') + }) + }) + }) + + // ========================================== + // User Interactions Testing + // ========================================== + describe('User Interactions', () => { + it('should call onProcess when Actions process button is clicked', () => { + // Arrange + const onProcess = jest.fn() + const props = createDefaultProps({ onProcess }) + + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + + // Assert + expect(onProcess).toHaveBeenCalledTimes(1) + }) + + it('should call onBack when Actions back button is clicked', () => { + // Arrange + const onBack = jest.fn() + const props = createDefaultProps({ onBack }) + + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) + + // Assert + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('should call onPreview when preview button is clicked', () => { + // Arrange + const onPreview = jest.fn() + const props = createDefaultProps({ onPreview }) + + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + + // Assert + expect(onPreview).toHaveBeenCalledTimes(1) + }) + + it('should call onSubmit when form is submitted', async () => { + // Arrange + const onSubmit = jest.fn() + const props = createDefaultProps({ onSubmit }) + const { container } = render() + + // Act + const form = container.querySelector('form')! + fireEvent.submit(form) + + // Assert + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + }) + }) + + // ========================================== + // Hook Integration Tests + // ========================================== + describe('Hook Integration', () => { + it('should pass variables from useInputVariables to useInitialData', () => { + // Arrange + const mockVariables = [{ variable: 'testVar', type: 'text', label: 'Test' }] + mockParamsConfig = { variables: mockVariables } + const { useInitialData } = require('@/app/components/rag-pipeline/hooks/use-input-fields') + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(useInitialData).toHaveBeenCalledWith(mockVariables) + }) + + it('should pass variables from useInputVariables to useConfigurations', () => { + // Arrange + const mockVariables = [{ variable: 'testVar', type: 'text', label: 'Test' }] + mockParamsConfig = { variables: mockVariables } + const { useConfigurations } = require('@/app/components/rag-pipeline/hooks/use-input-fields') + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(useConfigurations).toHaveBeenCalledWith(mockVariables) + }) + + it('should use empty array when paramsConfig.variables is undefined', () => { + // Arrange + mockParamsConfig = { variables: undefined as unknown as unknown[] } + const { useInitialData, useConfigurations } = require('@/app/components/rag-pipeline/hooks/use-input-fields') + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(useInitialData).toHaveBeenCalledWith([]) + expect(useConfigurations).toHaveBeenCalledWith([]) + }) + + it('should use empty array when paramsConfig is undefined', () => { + // Arrange + mockParamsConfig = undefined + const { useInitialData, useConfigurations } = require('@/app/components/rag-pipeline/hooks/use-input-fields') + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(useInitialData).toHaveBeenCalledWith([]) + expect(useConfigurations).toHaveBeenCalledWith([]) + }) + }) + + // ========================================== + // Actions runDisabled Testing + // ========================================== + describe('Actions runDisabled', () => { + it('should disable process button when isFetchingParams is true', () => { + // Arrange + mockIsFetchingParams = true + const props = createDefaultProps({ isRunning: false }) + + // Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).toBeDisabled() + }) + + it('should disable process button when isRunning is true', () => { + // Arrange + mockIsFetchingParams = false + const props = createDefaultProps({ isRunning: true }) + + // Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).toBeDisabled() + }) + + it('should enable process button when both isFetchingParams and isRunning are false', () => { + // Arrange + mockIsFetchingParams = false + const props = createDefaultProps({ isRunning: false }) + + // Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).not.toBeDisabled() + }) + + it('should disable process button when both isFetchingParams and isRunning are true', () => { + // Arrange + mockIsFetchingParams = true + const props = createDefaultProps({ isRunning: true }) + + // Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + expect(processButton).toBeDisabled() + }) + }) + + // ========================================== + // Component Memoization Testing + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert - verify component has memo wrapper + expect(ProcessDocuments.$$typeof).toBe(Symbol.for('react.memo')) + }) + + it('should render correctly after rerender with same props', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { rerender } = render() + rerender() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should update when dataSourceNodeId prop changes', () => { + // Arrange + const { useInputVariables } = require('./hooks') + const props = createDefaultProps({ dataSourceNodeId: 'node-1' }) + + // Act + const { rerender } = render() + expect(useInputVariables).toHaveBeenLastCalledWith('node-1') + + rerender() + + // Assert + expect(useInputVariables).toHaveBeenLastCalledWith('node-2') + }) + }) + + // ========================================== + // Edge Cases Testing + // ========================================== + describe('Edge Cases', () => { + it('should handle undefined paramsConfig gracefully', () => { + // Arrange + mockParamsConfig = undefined + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should handle empty variables array', () => { + // Arrange + mockParamsConfig = { variables: [] } + mockConfigurations = [] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should handle special characters in dataSourceNodeId', () => { + // Arrange + const { useInputVariables } = require('./hooks') + const props = createDefaultProps({ dataSourceNodeId: 'node-id-with-special_chars:123' }) + + // Act + render() + + // Assert + expect(useInputVariables).toHaveBeenCalledWith('node-id-with-special_chars:123') + }) + + it('should handle long dataSourceNodeId', () => { + // Arrange + const { useInputVariables } = require('./hooks') + const longId = 'a'.repeat(1000) + const props = createDefaultProps({ dataSourceNodeId: longId }) + + // Act + render() + + // Assert + expect(useInputVariables).toHaveBeenCalledWith(longId) + }) + + it('should handle multiple callbacks without interference', () => { + // Arrange + const onProcess = jest.fn() + const onBack = jest.fn() + const onPreview = jest.fn() + const props = createDefaultProps({ onProcess, onBack, onPreview }) + + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) + fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) + + // Assert + expect(onProcess).toHaveBeenCalledTimes(1) + expect(onBack).toHaveBeenCalledTimes(1) + expect(onPreview).toHaveBeenCalledTimes(1) + }) + }) + + // ========================================== + // runDisabled Logic Testing (with test.each) + // ========================================== + describe('runDisabled Logic', () => { + const runDisabledTestCases = [ + { isFetchingParams: false, isRunning: false, expectedDisabled: false }, + { isFetchingParams: false, isRunning: true, expectedDisabled: true }, + { isFetchingParams: true, isRunning: false, expectedDisabled: true }, + { isFetchingParams: true, isRunning: true, expectedDisabled: true }, + ] + + it.each(runDisabledTestCases)( + 'should set process button disabled=$expectedDisabled when isFetchingParams=$isFetchingParams and isRunning=$isRunning', + ({ isFetchingParams, isRunning, expectedDisabled }) => { + // Arrange + mockIsFetchingParams = isFetchingParams + const props = createDefaultProps({ isRunning }) + + // Act + render() + + // Assert + const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) + if (expectedDisabled) + expect(processButton).toBeDisabled() + else + expect(processButton).not.toBeDisabled() + }, + ) + }) + + // ========================================== + // Configuration Rendering Tests + // ========================================== + describe('Configuration Rendering', () => { + it('should render configurations as form fields', () => { + // Arrange + mockConfigurations = [ + createMockConfiguration({ variable: 'var1', label: 'Variable 1' }), + createMockConfiguration({ variable: 'var2', label: 'Variable 2' }), + ] + mockInitialData = { var1: '', var2: '' } + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('Variable 1')).toBeInTheDocument() + expect(screen.getByText('Variable 2')).toBeInTheDocument() + }) + + it('should handle configurations with different field types', () => { + // Arrange + mockConfigurations = [ + createMockConfiguration({ type: BaseFieldType.textInput, variable: 'textVar', label: 'Text Field' }), + createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'numberVar', label: 'Number Field' }), + ] + mockInitialData = { textVar: '', numberVar: 0 } + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('Text Field')).toBeInTheDocument() + expect(screen.getByText('Number Field')).toBeInTheDocument() + }) + }) + + // ========================================== + // Full Integration Props Testing + // ========================================== + describe('Full Prop Integration', () => { + it('should render correctly with all props provided', () => { + // Arrange + const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> + mockIsFetchingParams = false + mockParamsConfig = { variables: [{ variable: 'testVar', type: 'text', label: 'Test' }] } + mockInitialData = { testVar: 'initial value' } + mockConfigurations = [createMockConfiguration({ variable: 'testVar', label: 'Test Variable' })] + + const props = { + dataSourceNodeId: 'full-test-node', + ref: mockRef, + isRunning: false, + onProcess: jest.fn(), + onPreview: jest.fn(), + onSubmit: jest.fn(), + onBack: jest.fn(), + } + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() + expect(screen.getByText('Test Variable')).toBeInTheDocument() + expect(mockRef.current).not.toBeNull() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx new file mode 100644 index 0000000000..3684f3aef6 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx @@ -0,0 +1,1260 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import EmbeddingProcess from './index' +import type { DocumentIndexingStatus, IndexingStatusResponse } from '@/models/datasets' +import { DatasourceType, type InitialDocumentDetail } from '@/models/pipeline' +import { Plan } from '@/app/components/billing/type' +import { RETRIEVE_METHOD } from '@/types/app' +import { IndexingType } from '@/app/components/datasets/create/step-two' + +// ========================================== +// Mock External Dependencies +// ========================================== + +// Mock next/navigation +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock next/link +jest.mock('next/link', () => { + return function MockLink({ children, href, ...props }: { children: React.ReactNode; href: string }) { + return {children} + } +}) + +// Mock provider context +let mockEnableBilling = false +let mockPlanType: Plan = Plan.sandbox +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + enableBilling: mockEnableBilling, + plan: { type: mockPlanType }, + }), +})) + +// Mock useIndexingStatusBatch hook +let mockFetchIndexingStatus: jest.Mock +let mockIndexingStatusData: IndexingStatusResponse[] = [] +jest.mock('@/service/knowledge/use-dataset', () => ({ + useIndexingStatusBatch: () => ({ + mutateAsync: mockFetchIndexingStatus, + }), + useProcessRule: () => ({ + data: { + mode: 'custom', + rules: { parent_mode: 'paragraph' }, + }, + }), +})) + +// Mock useInvalidDocumentList hook +const mockInvalidDocumentList = jest.fn() +jest.mock('@/service/knowledge/use-document', () => ({ + useInvalidDocumentList: () => mockInvalidDocumentList, +})) + +// Mock useDatasetApiAccessUrl hook +jest.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', +})) + +// ========================================== +// Test Data Factory Functions +// ========================================== + +/** + * Creates a mock InitialDocumentDetail for testing + * Uses deterministic counter-based IDs to avoid flaky tests + */ +let documentIdCounter = 0 +const createMockDocument = (overrides: Partial = {}): InitialDocumentDetail => ({ + id: overrides.id ?? `doc-${++documentIdCounter}`, + name: 'test-document.txt', + data_source_type: DatasourceType.localFile, + data_source_info: {}, + enable: true, + error: '', + indexing_status: 'waiting' as DocumentIndexingStatus, + position: 0, + ...overrides, +}) + +/** + * Creates a mock IndexingStatusResponse for testing + */ +const createMockIndexingStatus = (overrides: Partial = {}): IndexingStatusResponse => ({ + id: `doc-${Math.random().toString(36).slice(2, 9)}`, + indexing_status: 'waiting' as DocumentIndexingStatus, + processing_started_at: Date.now(), + parsing_completed_at: 0, + cleaning_completed_at: 0, + splitting_completed_at: 0, + completed_at: null, + paused_at: null, + error: null, + stopped_at: null, + completed_segments: 0, + total_segments: 100, + ...overrides, +}) + +/** + * Creates default props for EmbeddingProcess component + */ +const createDefaultProps = (overrides: Partial<{ + datasetId: string + batchId: string + documents: InitialDocumentDetail[] + indexingType: IndexingType + retrievalMethod: RETRIEVE_METHOD +}> = {}) => ({ + datasetId: 'dataset-123', + batchId: 'batch-456', + documents: [createMockDocument({ id: 'doc-1', name: 'test-doc.pdf' })], + indexingType: IndexingType.QUALIFIED, + retrievalMethod: RETRIEVE_METHOD.semantic, + ...overrides, +}) + +// ========================================== +// Test Suite +// ========================================== + +describe('EmbeddingProcess', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + + // Reset deterministic ID counter for reproducible tests + documentIdCounter = 0 + + // Reset mock states + mockEnableBilling = false + mockPlanType = Plan.sandbox + mockIndexingStatusData = [] + + // Setup default mock for fetchIndexingStatus + mockFetchIndexingStatus = jest.fn().mockImplementation((_, options) => { + options?.onSuccess?.({ data: mockIndexingStatusData }) + options?.onSettled?.() + return Promise.resolve({ data: mockIndexingStatusData }) + }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + // Tests basic rendering functionality + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('rule-detail')).toBeInTheDocument() + }) + + it('should render RuleDetail component with correct props', () => { + // Arrange + const props = createDefaultProps({ + indexingType: IndexingType.ECONOMICAL, + retrievalMethod: RETRIEVE_METHOD.fullText, + }) + + // Act + render() + + // Assert - RuleDetail renders FieldInfo components with translated text + // Check that the component renders without error + expect(screen.getByTestId('rule-detail')).toBeInTheDocument() + }) + + it('should render API reference link with correct URL', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const apiLink = screen.getByRole('link', { name: /access the api/i }) + expect(apiLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + expect(apiLink).toHaveAttribute('target', '_blank') + expect(apiLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render navigation button', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.navTo')).toBeInTheDocument() + }) + }) + + // ========================================== + // Billing/Upgrade Banner Tests + // ========================================== + describe('Billing and Upgrade Banner', () => { + // Tests for billing-related UI + it('should not show upgrade banner when billing is disabled', () => { + // Arrange + mockEnableBilling = false + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() + }) + + it('should show upgrade banner when billing is enabled and plan is not team', () => { + // Arrange + mockEnableBilling = true + mockPlanType = Plan.sandbox + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() + }) + + it('should not show upgrade banner when plan is team', () => { + // Arrange + mockEnableBilling = true + mockPlanType = Plan.team + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() + }) + + it('should show upgrade banner for professional plan', () => { + // Arrange + mockEnableBilling = true + mockPlanType = Plan.professional + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() + }) + }) + + // ========================================== + // Status Display Tests + // ========================================== + describe('Status Display', () => { + // Tests for embedding status display + it('should show waiting status when all documents are waiting', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'waiting' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.waiting')).toBeInTheDocument() + }) + + it('should show processing status when any document is indexing', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() + }) + + it('should show processing status when any document is splitting', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'splitting' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() + }) + + it('should show processing status when any document is parsing', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'parsing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() + }) + + it('should show processing status when any document is cleaning', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'cleaning' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() + }) + + it('should show completed status when all documents are completed', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() + }) + + it('should show completed status when all documents have error status', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error', error: 'Processing failed' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() + }) + + it('should show completed status when all documents are paused', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'paused' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() + }) + }) + + // ========================================== + // Progress Bar Tests + // ========================================== + describe('Progress Display', () => { + // Tests for progress bar rendering + it('should show progress percentage for embedding documents', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'indexing', + completed_segments: 50, + total_segments: 100, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('50%')).toBeInTheDocument() + }) + + it('should cap progress at 100%', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'indexing', + completed_segments: 150, + total_segments: 100, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('100%')).toBeInTheDocument() + }) + + it('should show 0% when total_segments is 0', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'indexing', + completed_segments: 0, + total_segments: 0, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('0%')).toBeInTheDocument() + }) + + it('should not show progress for completed documents', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'completed', + completed_segments: 100, + total_segments: 100, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.queryByText('100%')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Polling Logic Tests + // ========================================== + describe('Polling Logic', () => { + // Tests for API polling behavior + it('should start polling on mount', async () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - verify fetch was called at least once + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + }) + + it('should continue polling while documents are processing', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + const initialCallCount = mockFetchIndexingStatus.mock.calls.length + + // Act + render() + + // Wait for initial fetch + await waitFor(() => { + expect(mockFetchIndexingStatus.mock.calls.length).toBeGreaterThan(initialCallCount) + }) + + const afterInitialCount = mockFetchIndexingStatus.mock.calls.length + + // Advance timer for next poll + jest.advanceTimersByTime(2500) + + // Assert - should poll again + await waitFor(() => { + expect(mockFetchIndexingStatus.mock.calls.length).toBeGreaterThan(afterInitialCount) + }) + }) + + it('should stop polling when all documents are completed', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + + // Wait for initial fetch and state update + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + const callCountAfterComplete = mockFetchIndexingStatus.mock.calls.length + + // Advance timer - polling should have stopped + jest.advanceTimersByTime(5000) + + // Assert - call count should not increase significantly after completion + // Note: Due to React Strict Mode, there might be double renders + expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual(callCountAfterComplete + 1) + }) + + it('should stop polling when all documents have errors', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + + // Wait for initial fetch + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + const callCountAfterError = mockFetchIndexingStatus.mock.calls.length + + // Advance timer + jest.advanceTimersByTime(5000) + + // Assert - should not poll significantly more after error state + expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual(callCountAfterError + 1) + }) + + it('should stop polling when all documents are paused', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'paused' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + + // Wait for initial fetch + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + const callCountAfterPaused = mockFetchIndexingStatus.mock.calls.length + + // Advance timer + jest.advanceTimersByTime(5000) + + // Assert - should not poll significantly more after paused state + expect(mockFetchIndexingStatus.mock.calls.length).toBeLessThanOrEqual(callCountAfterPaused + 1) + }) + + it('should cleanup timeout on unmount', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + const { unmount } = render() + + // Wait for initial fetch + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + const callCountBeforeUnmount = mockFetchIndexingStatus.mock.calls.length + + // Unmount before next poll + unmount() + + // Advance timer + jest.advanceTimersByTime(5000) + + // Assert - should not poll after unmount + expect(mockFetchIndexingStatus.mock.calls.length).toBe(callCountBeforeUnmount) + }) + }) + + // ========================================== + // User Interactions Tests + // ========================================== + describe('User Interactions', () => { + // Tests for button clicks and navigation + it('should navigate to document list when nav button is clicked', async () => { + // Arrange + const props = createDefaultProps({ datasetId: 'my-dataset-123' }) + + // Act + render() + const navButton = screen.getByText('datasetCreation.stepThree.navTo') + fireEvent.click(navButton) + + // Assert + expect(mockInvalidDocumentList).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/datasets/my-dataset-123/documents') + }) + + it('should call invalidDocumentList before navigation', () => { + // Arrange + const props = createDefaultProps() + const callOrder: string[] = [] + mockInvalidDocumentList.mockImplementation(() => callOrder.push('invalidate')) + mockPush.mockImplementation(() => callOrder.push('push')) + + // Act + render() + const navButton = screen.getByText('datasetCreation.stepThree.navTo') + fireEvent.click(navButton) + + // Assert + expect(callOrder).toEqual(['invalidate', 'push']) + }) + }) + + // ========================================== + // Document Display Tests + // ========================================== + describe('Document Display', () => { + // Tests for document list rendering + it('should display document names', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1', name: 'my-report.pdf' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('my-report.pdf')).toBeInTheDocument() + }) + + it('should display multiple documents', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1', name: 'file1.txt' }) + const doc2 = createMockDocument({ id: 'doc-2', name: 'file2.pdf' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'waiting' }), + ] + const props = createDefaultProps({ documents: [doc1, doc2] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('file1.txt')).toBeInTheDocument() + expect(screen.getByText('file2.pdf')).toBeInTheDocument() + }) + + it('should handle documents with special characters in names', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1', name: 'report_2024 (final) - copy.pdf' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('report_2024 (final) - copy.pdf')).toBeInTheDocument() + }) + }) + + // ========================================== + // Data Source Type Tests + // ========================================== + describe('Data Source Types', () => { + // Tests for different data source type displays + it('should handle local file data source', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'local-file.pdf', + data_source_type: DatasourceType.localFile, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('local-file.pdf')).toBeInTheDocument() + }) + + it('should handle online document data source', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'Notion Page', + data_source_type: DatasourceType.onlineDocument, + data_source_info: { notion_page_icon: { type: 'emoji', emoji: '๐Ÿ“„' } }, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('Notion Page')).toBeInTheDocument() + }) + + it('should handle website crawl data source', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'https://example.com/page', + data_source_type: DatasourceType.websiteCrawl, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should handle online drive data source', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'Google Drive Document', + data_source_type: DatasourceType.onlineDrive, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('Google Drive Document')).toBeInTheDocument() + }) + }) + + // ========================================== + // Error Handling Tests + // ========================================== + describe('Error Handling', () => { + // Tests for error states and displays + it('should display error icon for documents with error status', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'error', + error: 'Failed to process document', + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + const { container } = render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - error icon should be visible + const errorIcon = container.querySelector('.text-text-destructive') + expect(errorIcon).toBeInTheDocument() + }) + + it('should apply error styling to document row with error', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: 'error', + error: 'Processing failed', + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + const { container } = render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - should have error background class + const errorRow = container.querySelector('.bg-state-destructive-hover-alt') + expect(errorRow).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases + // ========================================== + describe('Edge Cases', () => { + // Tests for boundary conditions + it('should throw error when documents array is empty', () => { + // Arrange + // The component accesses documents[0].id for useProcessRule (line 81-82), + // which throws TypeError when documents array is empty. + // This test documents this known limitation. + const props = createDefaultProps({ documents: [] }) + + // Suppress console errors for expected error + const consoleError = jest.spyOn(console, 'error').mockImplementation(Function.prototype as () => void) + + // Act & Assert - explicitly assert the error behavior + expect(() => { + render() + }).toThrow(TypeError) + + consoleError.mockRestore() + }) + + it('should handle empty indexing status response', async () => { + // Arrange + mockIndexingStatusData = [] + const props = createDefaultProps() + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - should not show any status text when empty + expect(screen.queryByText('datasetDocuments.embedding.waiting')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.embedding.processing')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.embedding.completed')).not.toBeInTheDocument() + }) + + it('should handle document with undefined name', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1', name: undefined as unknown as string }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act & Assert - should not throw + expect(() => render()).not.toThrow() + }) + + it('should handle document not found in indexing status', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'other-doc', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act & Assert - should not throw + expect(() => render()).not.toThrow() + }) + + it('should handle undefined indexing_status', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ + id: 'doc-1', + indexing_status: undefined as unknown as DocumentIndexingStatus, + }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act & Assert - should not throw + expect(() => render()).not.toThrow() + }) + + it('should handle mixed status documents', async () => { + // Arrange + const doc1 = createMockDocument({ id: 'doc-1' }) + const doc2 = createMockDocument({ id: 'doc-2' }) + const doc3 = createMockDocument({ id: 'doc-3' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'indexing' }), + createMockIndexingStatus({ id: 'doc-3', indexing_status: 'error' }), + ] + const props = createDefaultProps({ documents: [doc1, doc2, doc3] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - should show processing (since one is still indexing) + expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Variations Tests + // ========================================== + describe('Props Variations', () => { + // Tests for different prop combinations + it('should handle undefined indexingType', () => { + // Arrange + const props = createDefaultProps({ indexingType: undefined }) + + // Act + render() + + // Assert - component renders without crashing + expect(screen.getByTestId('rule-detail')).toBeInTheDocument() + }) + + it('should handle undefined retrievalMethod', () => { + // Arrange + const props = createDefaultProps({ retrievalMethod: undefined }) + + // Act + render() + + // Assert - component renders without crashing + expect(screen.getByTestId('rule-detail')).toBeInTheDocument() + }) + + it('should pass different indexingType values', () => { + // Arrange + const indexingTypes = [IndexingType.QUALIFIED, IndexingType.ECONOMICAL] + + indexingTypes.forEach((indexingType) => { + const props = createDefaultProps({ indexingType }) + + // Act + const { unmount } = render() + + // Assert - RuleDetail renders and shows appropriate text based on indexingType + expect(screen.getByTestId('rule-detail')).toBeInTheDocument() + + unmount() + }) + }) + + it('should pass different retrievalMethod values', () => { + // Arrange + const retrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText, RETRIEVE_METHOD.hybrid] + + retrievalMethods.forEach((retrievalMethod) => { + const props = createDefaultProps({ retrievalMethod }) + + // Act + const { unmount } = render() + + // Assert - RuleDetail renders and shows appropriate text based on retrievalMethod + expect(screen.getByTestId('rule-detail')).toBeInTheDocument() + + unmount() + }) + }) + }) + + // ========================================== + // Memoization Tests + // ========================================== + describe('Memoization Logic', () => { + // Tests for useMemo computed values + it('should correctly compute isEmbeddingWaiting', async () => { + // Arrange - all waiting + const doc1 = createMockDocument({ id: 'doc-1' }) + const doc2 = createMockDocument({ id: 'doc-2' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'waiting' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'waiting' }), + ] + const props = createDefaultProps({ documents: [doc1, doc2] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.waiting')).toBeInTheDocument() + }) + + it('should correctly compute isEmbedding when one is indexing', async () => { + // Arrange - one waiting, one indexing + const doc1 = createMockDocument({ id: 'doc-1' }) + const doc2 = createMockDocument({ id: 'doc-2' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'waiting' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1, doc2] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() + }) + + it('should correctly compute isEmbeddingCompleted for mixed terminal states', async () => { + // Arrange - completed + error + paused = all terminal + const doc1 = createMockDocument({ id: 'doc-1' }) + const doc2 = createMockDocument({ id: 'doc-2' }) + const doc3 = createMockDocument({ id: 'doc-3' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'error' }), + createMockIndexingStatus({ id: 'doc-3', indexing_status: 'paused' }), + ] + const props = createDefaultProps({ documents: [doc1, doc2, doc3] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() + }) + }) + + // ========================================== + // File Type Detection Tests + // ========================================== + describe('File Type Detection', () => { + // Tests for getFileType helper function + it('should extract file extension correctly', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'document.pdf', + data_source_type: DatasourceType.localFile, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - file should be displayed (file type detection happens internally) + expect(screen.getByText('document.pdf')).toBeInTheDocument() + }) + + it('should handle files with multiple dots', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'my.report.2024.pdf', + data_source_type: DatasourceType.localFile, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('my.report.2024.pdf')).toBeInTheDocument() + }) + + it('should handle files without extension', async () => { + // Arrange + const doc1 = createMockDocument({ + id: 'doc-1', + name: 'README', + data_source_type: DatasourceType.localFile, + }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert + expect(screen.getByText('README')).toBeInTheDocument() + }) + }) + + // ========================================== + // Priority Label Tests + // ========================================== + describe('Priority Label', () => { + // Tests for priority label display + it('should show priority label when billing is enabled', async () => { + // Arrange + mockEnableBilling = true + mockPlanType = Plan.sandbox + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + const { container } = render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - PriorityLabel component should be rendered + // Since we don't mock PriorityLabel, we check the structure exists + expect(container.querySelector('.ml-0')).toBeInTheDocument() + }) + + it('should not show priority label when billing is disabled', async () => { + // Arrange + mockEnableBilling = false + const doc1 = createMockDocument({ id: 'doc-1' }) + mockIndexingStatusData = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), + ] + const props = createDefaultProps({ documents: [doc1] }) + + // Act + render() + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }) + + // Assert - upgrade banner should not be present + expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx new file mode 100644 index 0000000000..0f7d3855e6 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx @@ -0,0 +1,475 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import RuleDetail from './rule-detail' +import { ProcessMode, type ProcessRuleResponse } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { IndexingType } from '@/app/components/datasets/create/step-two' + +// ========================================== +// Mock External Dependencies +// ========================================== + +// Mock next/image (using img element for simplicity in tests) +jest.mock('next/image', () => ({ + __esModule: true, + default: function MockImage({ src, alt, className }: { src: string; alt: string; className?: string }) { + // eslint-disable-next-line @next/next/no-img-element + return {alt} + }, +})) + +// Mock FieldInfo component +jest.mock('@/app/components/datasets/documents/detail/metadata', () => ({ + FieldInfo: ({ label, displayedValue, valueIcon }: { label: string; displayedValue: string; valueIcon?: React.ReactNode }) => ( +
+ {label} + {displayedValue} + {valueIcon && {valueIcon}} +
+ ), +})) + +// Mock icons - provides simple string paths for testing instead of Next.js static import objects +jest.mock('@/app/components/datasets/create/icons', () => ({ + indexMethodIcon: { + economical: '/icons/economical.svg', + high_quality: '/icons/high_quality.svg', + }, + retrievalIcon: { + fullText: '/icons/fullText.svg', + hybrid: '/icons/hybrid.svg', + vector: '/icons/vector.svg', + }, +})) + +// ========================================== +// Test Data Factory Functions +// ========================================== + +/** + * Creates a mock ProcessRuleResponse for testing + */ +const createMockProcessRule = (overrides: Partial = {}): ProcessRuleResponse => ({ + mode: ProcessMode.general, + rules: { + pre_processing_rules: [], + segmentation: { + separator: '\n', + max_tokens: 500, + chunk_overlap: 50, + }, + parent_mode: 'paragraph', + subchunk_segmentation: { + separator: '\n', + max_tokens: 200, + chunk_overlap: 20, + }, + }, + limits: { + indexing_max_segmentation_tokens_length: 1000, + }, + ...overrides, +}) + +// ========================================== +// Test Suite +// ========================================== + +describe('RuleDetail', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + const fieldInfos = screen.getAllByTestId('field-info') + expect(fieldInfos).toHaveLength(3) + }) + + it('should render three FieldInfo components', () => { + // Arrange + const sourceData = createMockProcessRule() + + // Act + render( + , + ) + + // Assert + const fieldInfos = screen.getAllByTestId('field-info') + expect(fieldInfos).toHaveLength(3) + }) + + it('should render mode field with correct label', () => { + // Arrange & Act + render() + + // Assert - first field-info is for mode + const fieldInfos = screen.getAllByTestId('field-info') + expect(fieldInfos[0]).toHaveAttribute('data-label', 'datasetDocuments.embedding.mode') + }) + }) + + // ========================================== + // Mode Value Tests + // ========================================== + describe('Mode Value', () => { + it('should show "-" when sourceData is undefined', () => { + // Arrange & Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[0]).toHaveTextContent('-') + }) + + it('should show "-" when sourceData.mode is undefined', () => { + // Arrange + const sourceData = { ...createMockProcessRule(), mode: undefined as unknown as ProcessMode } + + // Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[0]).toHaveTextContent('-') + }) + + it('should show custom mode text when mode is general', () => { + // Arrange + const sourceData = createMockProcessRule({ mode: ProcessMode.general }) + + // Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.custom') + }) + + it('should show hierarchical mode with paragraph parent mode', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.parentChild, + rules: { + pre_processing_rules: [], + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 }, + }, + }) + + // Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical ยท dataset.parentMode.paragraph') + }) + + it('should show hierarchical mode with full-doc parent mode', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.parentChild, + rules: { + pre_processing_rules: [], + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 }, + }, + }) + + // Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical ยท dataset.parentMode.fullDoc') + }) + }) + + // ========================================== + // Indexing Type Tests + // ========================================== + describe('Indexing Type', () => { + it('should show qualified indexing type', () => { + // Arrange & Act + render() + + // Assert + const fieldInfos = screen.getAllByTestId('field-info') + expect(fieldInfos[1]).toHaveAttribute('data-label', 'datasetCreation.stepTwo.indexMode') + + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') + }) + + it('should show economical indexing type', () => { + // Arrange & Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.economical') + }) + + it('should show high_quality icon for qualified indexing', () => { + // Arrange & Act + render() + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') + }) + + it('should show economical icon for economical indexing', () => { + // Arrange & Act + render() + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') + }) + }) + + // ========================================== + // Retrieval Method Tests + // ========================================== + describe('Retrieval Method', () => { + it('should show retrieval setting label', () => { + // Arrange & Act + render() + + // Assert + const fieldInfos = screen.getAllByTestId('field-info') + expect(fieldInfos[2]).toHaveAttribute('data-label', 'datasetSettings.form.retrievalSetting.title') + }) + + it('should show semantic search title for qualified indexing with semantic method', () => { + // Arrange & Act + render( + , + ) + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.semantic_search.title') + }) + + it('should show full text search title for fullText method', () => { + // Arrange & Act + render( + , + ) + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.full_text_search.title') + }) + + it('should show hybrid search title for hybrid method', () => { + // Arrange & Act + render( + , + ) + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.hybrid_search.title') + }) + + it('should force keyword_search for economical indexing type', () => { + // Arrange & Act + render( + , + ) + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.keyword_search.title') + }) + + it('should show vector icon for semantic search', () => { + // Arrange & Act + render( + , + ) + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') + }) + + it('should show fullText icon for full text search', () => { + // Arrange & Act + render( + , + ) + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') + }) + + it('should show hybrid icon for hybrid search', () => { + // Arrange & Act + render( + , + ) + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') + }) + }) + + // ========================================== + // Edge Cases + // ========================================== + describe('Edge Cases', () => { + it('should handle all props undefined', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getAllByTestId('field-info')).toHaveLength(3) + }) + + it('should handle undefined indexingType with defined retrievalMethod', () => { + // Arrange & Act + render() + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + // When indexingType is undefined, it's treated as qualified + expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') + }) + + it('should handle undefined retrievalMethod with defined indexingType', () => { + // Arrange & Act + render() + + // Assert + const images = screen.getAllByTestId('next-image') + // When retrievalMethod is undefined, vector icon is used as default + expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') + }) + + it('should handle sourceData with null rules', () => { + // Arrange + const sourceData = { + ...createMockProcessRule(), + mode: ProcessMode.parentChild, + rules: null as unknown as ProcessRuleResponse['rules'], + } + + // Act & Assert - should not crash + render() + expect(screen.getAllByTestId('field-info')).toHaveLength(3) + }) + }) + + // ========================================== + // Props Variations Tests + // ========================================== + describe('Props Variations', () => { + it('should render correctly with all props provided', () => { + // Arrange + const sourceData = createMockProcessRule({ mode: ProcessMode.general }) + + // Act + render( + , + ) + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.custom') + expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') + expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.semantic_search.title') + }) + + it('should render correctly for economical mode with full settings', () => { + // Arrange + const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild }) + + // Act + render( + , + ) + + // Assert + const fieldValues = screen.getAllByTestId('field-value') + expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.economical') + // Economical always uses keyword_search regardless of retrievalMethod + expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.keyword_search.title') + }) + }) + + // ========================================== + // Memoization Tests + // ========================================== + describe('Memoization', () => { + it('should be wrapped in React.memo', () => { + // Assert - RuleDetail should be a memoized component + expect(RuleDetail).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + + it('should not re-render with same props', () => { + // Arrange + const sourceData = createMockProcessRule() + const props = { + sourceData, + indexingType: IndexingType.QUALIFIED, + retrievalMethod: RETRIEVE_METHOD.semantic, + } + + // Act + const { rerender } = render() + rerender() + + // Assert - component renders correctly after rerender + expect(screen.getAllByTestId('field-info')).toHaveLength(3) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index c8b1375069..39c10761cb 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -39,7 +39,7 @@ const RuleDetail = ({ }, [sourceData, t]) return ( -
+
({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock useDocLink - returns a function that generates doc URLs +// Strips leading slash from path to match actual implementation behavior +jest.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => { + const normalizedPath = path?.startsWith('/') ? path.slice(1) : (path || '') + return `https://docs.dify.ai/en-US/${normalizedPath}` + }, +})) + +// Mock dataset detail context +let mockDataset: { + id?: string + indexing_technique?: string + retrieval_model_dict?: { search_method?: string } +} | undefined + +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: typeof mockDataset }) => T): T => { + return selector({ dataset: mockDataset }) + }, +})) + +// Mock the EmbeddingProcess component to track props +let embeddingProcessProps: Record = {} +jest.mock('./embedding-process', () => ({ + __esModule: true, + default: (props: Record) => { + embeddingProcessProps = props + return ( +
+ {props.datasetId as string} + {props.batchId as string} + {(props.documents as unknown[])?.length ?? 0} + {props.indexingType as string || 'undefined'} + {props.retrievalMethod as string || 'undefined'} +
+ ) + }, +})) + +// ========================================== +// Test Data Factory Functions +// ========================================== + +/** + * Creates a mock InitialDocumentDetail for testing + * Uses deterministic counter-based IDs to avoid flaky tests + */ +let documentIdCounter = 0 +const createMockDocument = (overrides: Partial = {}): InitialDocumentDetail => ({ + id: overrides.id ?? `doc-${++documentIdCounter}`, + name: 'test-document.txt', + data_source_type: DatasourceType.localFile, + data_source_info: {}, + enable: true, + error: '', + indexing_status: 'waiting' as DocumentIndexingStatus, + position: 0, + ...overrides, +}) + +/** + * Creates a list of mock documents + */ +const createMockDocuments = (count: number): InitialDocumentDetail[] => + Array.from({ length: count }, (_, index) => + createMockDocument({ + id: `doc-${index + 1}`, + name: `document-${index + 1}.txt`, + position: index, + }), + ) + +// ========================================== +// Test Suite +// ========================================== + +describe('Processing', () => { + beforeEach(() => { + jest.clearAllMocks() + embeddingProcessProps = {} + // Reset deterministic ID counter for reproducible tests + documentIdCounter = 0 + // Reset mock dataset with default values + mockDataset = { + id: 'dataset-123', + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: 'semantic_search' }, + } + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + // Tests basic rendering functionality + it('should render without crashing', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(2), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should render the EmbeddingProcess component', () => { + // Arrange + const props = { + batchId: 'batch-456', + documents: createMockDocuments(3), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should render the side tip section with correct content', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert - verify translation keys are rendered + expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() + }) + + it('should render the documentation link with correct attributes', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + const link = screen.getByRole('link', { name: 'datasetPipeline.addDocuments.stepThree.learnMore' }) + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noreferrer noopener') + }) + + it('should render the book icon in the side tip', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + const { container } = render() + + // Assert - check for icon container with shadow styling + const iconContainer = container.querySelector('.shadow-lg.shadow-shadow-shadow-5') + expect(iconContainer).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + // Tests that props are correctly passed to child components + it('should pass batchId to EmbeddingProcess', () => { + // Arrange + const testBatchId = 'test-batch-id-789' + const props = { + batchId: testBatchId, + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent(testBatchId) + expect(embeddingProcessProps.batchId).toBe(testBatchId) + }) + + it('should pass documents to EmbeddingProcess', () => { + // Arrange + const documents = createMockDocuments(5) + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('5') + expect(embeddingProcessProps.documents).toEqual(documents) + }) + + it('should pass datasetId from context to EmbeddingProcess', () => { + // Arrange + mockDataset = { + id: 'context-dataset-id', + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: 'semantic_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('context-dataset-id') + expect(embeddingProcessProps.datasetId).toBe('context-dataset-id') + }) + + it('should pass indexingType from context to EmbeddingProcess', () => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: 'economy', + retrieval_model_dict: { search_method: 'semantic_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') + expect(embeddingProcessProps.indexingType).toBe('economy') + }) + + it('should pass retrievalMethod from context to EmbeddingProcess', () => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: 'keyword_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('keyword_search') + expect(embeddingProcessProps.retrievalMethod).toBe('keyword_search') + }) + + it('should handle different document types', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-local', + name: 'local-file.pdf', + data_source_type: DatasourceType.localFile, + }), + createMockDocument({ + id: 'doc-online', + name: 'online-doc', + data_source_type: DatasourceType.onlineDocument, + }), + createMockDocument({ + id: 'doc-website', + name: 'website-page', + data_source_type: DatasourceType.websiteCrawl, + }), + ] + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') + expect(embeddingProcessProps.documents).toEqual(documents) + }) + }) + + // ========================================== + // Edge Cases + // ========================================== + describe('Edge Cases', () => { + // Tests for boundary conditions and unusual inputs + it('should handle empty documents array', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: [], + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') + expect(embeddingProcessProps.documents).toEqual([]) + }) + + it('should handle empty batchId', () => { + // Arrange + const props = { + batchId: '', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') + }) + + it('should handle undefined dataset from context', () => { + // Arrange + mockDataset = undefined + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.datasetId).toBeUndefined() + expect(embeddingProcessProps.indexingType).toBeUndefined() + expect(embeddingProcessProps.retrievalMethod).toBeUndefined() + }) + + it('should handle dataset with undefined id', () => { + // Arrange + mockDataset = { + id: undefined, + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: 'semantic_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.datasetId).toBeUndefined() + }) + + it('should handle dataset with undefined indexing_technique', () => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: undefined, + retrieval_model_dict: { search_method: 'semantic_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.indexingType).toBeUndefined() + }) + + it('should handle dataset with undefined retrieval_model_dict', () => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: 'high_quality', + retrieval_model_dict: undefined, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.retrievalMethod).toBeUndefined() + }) + + it('should handle dataset with empty retrieval_model_dict', () => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: 'high_quality', + retrieval_model_dict: {}, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.retrievalMethod).toBeUndefined() + }) + + it('should handle large number of documents', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(100), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('100') + }) + + it('should handle documents with error status', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-error', + name: 'error-doc.txt', + error: 'Processing failed', + indexing_status: 'error' as DocumentIndexingStatus, + }), + ] + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.documents).toEqual(documents) + }) + + it('should handle documents with special characters in names', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-special', + name: 'document with spaces & special-chars_ๆต‹่ฏ•.pdf', + }), + ] + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.documents).toEqual(documents) + }) + + it('should handle batchId with special characters', () => { + // Arrange + const props = { + batchId: 'batch-123-abc_xyz:456', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('batch-123-abc_xyz:456') + }) + }) + + // ========================================== + // Context Integration Tests + // ========================================== + describe('Context Integration', () => { + // Tests for proper context usage + it('should correctly use context selectors for all dataset properties', () => { + // Arrange + mockDataset = { + id: 'full-dataset-id', + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: 'hybrid_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(embeddingProcessProps.datasetId).toBe('full-dataset-id') + expect(embeddingProcessProps.indexingType).toBe('high_quality') + expect(embeddingProcessProps.retrievalMethod).toBe('hybrid_search') + }) + + it('should handle context changes with different indexing techniques', () => { + // Arrange - Test with economy indexing + mockDataset = { + id: 'dataset-economy', + indexing_technique: 'economy', + retrieval_model_dict: { search_method: 'keyword_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + const { rerender } = render() + + // Assert economy indexing + expect(embeddingProcessProps.indexingType).toBe('economy') + + // Arrange - Update to high_quality + mockDataset = { + id: 'dataset-hq', + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: 'semantic_search' }, + } + + // Act - Rerender with new context + rerender() + + // Assert high_quality indexing + expect(embeddingProcessProps.indexingType).toBe('high_quality') + }) + }) + + // ========================================== + // Layout Tests + // ========================================== + describe('Layout', () => { + // Tests for proper layout and structure + it('should render with correct layout structure', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + const { container } = render() + + // Assert - Check for flex layout with proper widths + const mainContainer = container.querySelector('.flex.h-full.w-full.justify-center') + expect(mainContainer).toBeInTheDocument() + + // Check for left panel (3/5 width) + const leftPanel = container.querySelector('.w-3\\/5') + expect(leftPanel).toBeInTheDocument() + + // Check for right panel (2/5 width) + const rightPanel = container.querySelector('.w-2\\/5') + expect(rightPanel).toBeInTheDocument() + }) + + it('should render side tip card with correct styling', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + const { container } = render() + + // Assert - Check for card container with rounded corners and background + const sideTipCard = container.querySelector('.rounded-xl.bg-background-section') + expect(sideTipCard).toBeInTheDocument() + }) + + it('should constrain max-width for EmbeddingProcess container', () => { + // Arrange + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + const { container } = render() + + // Assert + const maxWidthContainer = container.querySelector('.max-w-\\[640px\\]') + expect(maxWidthContainer).toBeInTheDocument() + }) + }) + + // ========================================== + // Document Variations Tests + // ========================================== + describe('Document Variations', () => { + // Tests for different document configurations + it('should handle documents with all indexing statuses', () => { + // Arrange + const statuses: DocumentIndexingStatus[] = [ + 'waiting', + 'parsing', + 'cleaning', + 'splitting', + 'indexing', + 'paused', + 'error', + 'completed', + ] + const documents = statuses.map((status, index) => + createMockDocument({ + id: `doc-${status}`, + name: `${status}-doc.txt`, + indexing_status: status, + position: index, + }), + ) + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent(String(statuses.length)) + expect(embeddingProcessProps.documents).toEqual(documents) + }) + + it('should handle documents with enabled and disabled states', () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-enabled', enable: true }), + createMockDocument({ id: 'doc-disabled', enable: false }), + ] + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('2') + expect(embeddingProcessProps.documents).toEqual(documents) + }) + + it('should handle documents from online drive source', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-drive', + name: 'google-drive-doc', + data_source_type: DatasourceType.onlineDrive, + data_source_info: { provider: 'google_drive' }, + }), + ] + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + expect(embeddingProcessProps.documents).toEqual(documents) + }) + + it('should handle documents with complex data_source_info', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-notion', + name: 'Notion Page', + data_source_type: DatasourceType.onlineDocument, + data_source_info: { + notion_page_icon: { type: 'emoji', emoji: '๐Ÿ“„' }, + notion_workspace_id: 'ws-123', + notion_page_id: 'page-456', + }, + }), + ] + const props = { + batchId: 'batch-123', + documents, + } + + // Act + render() + + // Assert + expect(embeddingProcessProps.documents).toEqual(documents) + }) + }) + + // ========================================== + // Retrieval Method Variations + // ========================================== + describe('Retrieval Method Variations', () => { + // Tests for different retrieval methods + const retrievalMethods = ['semantic_search', 'keyword_search', 'hybrid_search', 'full_text_search'] + + it.each(retrievalMethods)('should handle %s retrieval method', (method) => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: 'high_quality', + retrieval_model_dict: { search_method: method }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(embeddingProcessProps.retrievalMethod).toBe(method) + }) + }) + + // ========================================== + // Indexing Technique Variations + // ========================================== + describe('Indexing Technique Variations', () => { + // Tests for different indexing techniques + const indexingTechniques = ['high_quality', 'economy'] + + it.each(indexingTechniques)('should handle %s indexing technique', (technique) => { + // Arrange + mockDataset = { + id: 'dataset-123', + indexing_technique: technique, + retrieval_model_dict: { search_method: 'semantic_search' }, + } + const props = { + batchId: 'batch-123', + documents: createMockDocuments(1), + } + + // Act + render() + + // Assert + expect(embeddingProcessProps.indexingType).toBe(technique) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx index 7215f23345..f48143948e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/index.tsx @@ -20,7 +20,7 @@ const Processing = ({ const docLink = useDocLink() const datasetId = useDatasetDetailContextWithSelector(s => s.dataset?.id) const indexingType = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique) - const retrievalMethod = useDatasetDetailContextWithSelector(s => s.dataset?.retrieval_model_dict.search_method) + const retrievalMethod = useDatasetDetailContextWithSelector(s => s.dataset?.retrieval_model_dict?.search_method) return (
diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx index ced1bf05a9..115189ec99 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx @@ -52,11 +52,10 @@ jest.mock('../index', () => ({ })) // ============================================================================ -// Component Mocks - components with complex ESM dependencies (ky, react-pdf-highlighter, etc.) -// These are mocked to avoid Jest ESM parsing issues, not because they're external +// Component Mocks - components with complex dependencies // ============================================================================ -// StatusItem has deep dependency: use-document hooks โ†’ service/base โ†’ ky (ESM) +// StatusItem uses React Query hooks which require QueryClientProvider jest.mock('../../../status-item', () => ({ __esModule: true, default: ({ status, reverse, textCls }: { status: string; reverse?: boolean; textCls?: string }) => ( @@ -66,7 +65,7 @@ jest.mock('../../../status-item', () => ({ ), })) -// ImageList has deep dependency: FileThumb โ†’ file-uploader โ†’ ky, react-pdf-highlighter (ESM) +// ImageList has deep dependency: FileThumb โ†’ file-uploader โ†’ react-pdf-highlighter (ESM) jest.mock('@/app/components/datasets/common/image-list', () => ({ __esModule: true, default: ({ images, size, className }: { images: Array<{ sourceUrl: string; name: string }>; size?: string; className?: string }) => ( diff --git a/web/app/components/explore/create-app-modal/index.spec.tsx b/web/app/components/explore/create-app-modal/index.spec.tsx index fdae9bba2f..7f68b33337 100644 --- a/web/app/components/explore/create-app-modal/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/index.spec.tsx @@ -33,16 +33,6 @@ jest.mock('react-i18next', () => ({ }, })) -// ky is an ESM-only package; mock it to keep Jest (CJS) specs running. -jest.mock('ky', () => ({ - __esModule: true, - default: { - create: () => ({ - extend: () => async () => new Response(), - }), - }, -})) - // Avoid heavy emoji dataset initialization during unit tests. jest.mock('emoji-mart', () => ({ init: jest.fn(), diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx index cbeecaf26f..1fff507889 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx @@ -11,24 +11,6 @@ const mockResetWorkflowVersionHistory = jest.fn() let appDetail: App -jest.mock('ky', () => ({ - __esModule: true, - default: { - create: () => ({ - extend: () => async () => ({ - status: 200, - headers: new Headers(), - json: async () => ({}), - blob: async () => new Blob(), - clone: () => ({ - status: 200, - json: async () => ({}), - }), - }), - }), - }, -})) - jest.mock('@/app/components/app/store', () => ({ __esModule: true, useStore: (selector: (state: { appDetail?: App; setCurrentLogItem: typeof mockSetCurrentLogItem; setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector), diff --git a/web/jest.config.ts b/web/jest.config.ts index 434b19270f..86e86fa2ac 100644 --- a/web/jest.config.ts +++ b/web/jest.config.ts @@ -101,7 +101,11 @@ const config: Config = { // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { '^@/(.*)$': '/$1', + // Map lodash-es to lodash (CommonJS version) '^lodash-es$': 'lodash', + '^lodash-es/(.*)$': 'lodash/$1', + // Mock ky ESM module to avoid ESM issues in Jest + '^ky$': '/__mocks__/ky.ts', }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader