feat(tests): add comprehensive tests for Processing and EmbeddingProcess components (#29873)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Coding On Star
2025-12-19 15:21:21 +08:00
committed by GitHub
parent 933bc72fd7
commit d7b8db2afc
19 changed files with 7015 additions and 229 deletions

71
web/__mocks__/ky.ts Normal file
View File

@@ -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 }

View File

@@ -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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={mockApp} />)
// 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(<AppCard app={mockApp} />)
// 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(<AppCard app={mockApp} />)
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<string>) => {
await callback()
})
render(<AppCard app={mockApp} />)
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<string>, options: any) => {
try {
await callback()
}
catch (err) {
options?.onError?.(err)
}
})
render(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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<string>, options: any) => {
try {
await callback()
}
catch (err) {
options?.onError?.(err)
}
})
render(<AppCard app={mockApp} />)
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<string>) => {
return await callback()
})
render(<AppCard app={mockApp} />)
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(<AppCard app={draftTriggerApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
await waitFor(() => {
expect(screen.getByText('app.accessControl')).toBeInTheDocument()
})
})
it('should click access control button', async () => {
render(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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()
})
})
})
})

View File

@@ -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(<List />)
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(<List />)
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(<List />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
// Input component renders a searchbox
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
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(<List />)
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(<List />)
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(<List />)
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(<List />)
expect(screen.getByTestId('search-input-field')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle search input change', () => {
render(<List />)
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(<List />)
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(<List />)
// 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(<List />)
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(<List />)
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(<List />)
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(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
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(<List />)
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(<List />)
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(<List />)
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(<List />)
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(<List />)
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(<List />)
// 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(<List />)
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(<List />)
// 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(<List />)
// 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(<List />)
// 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(<List />)
// 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(<List />)
// 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(<List />)
// 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(<List />)
// 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(<List />)
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(<List />)
// Component should still render
expect(container).toBeInTheDocument()
// Disconnect should be called when there's an error (cleanup)
})
})
})

View File

@@ -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 }) => (
<a href={href} data-replace={replace}>
{children}
</a>
)
})
// ==========================================
// 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(<Actions {...defaultProps} />)
// Assert
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeInTheDocument()
})
it('should render cancel button with correct link', () => {
// Arrange & Act
render(<Actions {...defaultProps} />)
// 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(<Actions {...defaultProps} />)
// 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(<Actions {...defaultProps} />)
// Assert
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
})
it('should not render select all section by default', () => {
// Arrange & Act
render(<Actions {...defaultProps} />)
// 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(<Actions {...defaultProps} disabled={false} />)
// 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(<Actions {...defaultProps} disabled={true} />)
// 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(<Actions {...defaultProps} disabled={undefined} />)
// 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(<Actions {...defaultProps} showSelect={true} onSelectAll={jest.fn()} />)
// Assert
expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument()
})
it('should hide select all section when showSelect is false', () => {
// Arrange & Act
render(<Actions {...defaultProps} showSelect={false} />)
// Assert
expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
})
it('should hide select all section when showSelect defaults to false', () => {
// Arrange & Act
render(<Actions handleNextStep={jest.fn()} />)
// 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(<Actions {...defaultProps} showSelect={true} tip={tip} onSelectAll={jest.fn()} />)
// 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(<Actions {...defaultProps} showSelect={false} tip={tip} />)
// Assert
expect(screen.queryByText(tip)).not.toBeInTheDocument()
})
it('should not show tip when tip is empty string', () => {
// Arrange & Act
render(<Actions {...defaultProps} showSelect={true} tip="" onSelectAll={jest.fn()} />)
// 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(<Actions {...defaultProps} showSelect={true} onSelectAll={jest.fn()} />)
// 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(<Actions {...defaultProps} handleNextStep={handleNextStep} />)
// 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(<Actions {...defaultProps} handleNextStep={handleNextStep} disabled={true} />)
// 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(
<Actions
{...defaultProps}
showSelect={true}
onSelectAll={onSelectAll}
totalOptions={5}
selectedOptions={0}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={false}
totalOptions={5}
selectedOptions={2}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={undefined}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={undefined}
selectedOptions={2}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={3}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={0}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={5}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={false}
totalOptions={5}
selectedOptions={5}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={undefined}
onSelectAll={jest.fn()}
/>,
)
// Assert
const checkbox = container.querySelector('[class*="cursor-pointer"]')
expect(checkbox).toBeInTheDocument()
})
it('should return false when totalOptions is undefined', () => {
// Arrange & Act
const { container } = render(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={undefined}
selectedOptions={5}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={5}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={0}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={4}
onSelectAll={jest.fn()}
/>,
)
// 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(<Actions {...props} />)
// Re-render with same props
rerender(<Actions {...props} />)
// 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(<Actions {...initialProps} />)
expect(screen.getByText('Initial tip')).toBeInTheDocument()
// Rerender with different props
rerender(<Actions {...initialProps} tip="Updated tip" />)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={0}
selectedOptions={0}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={1000000}
selectedOptions={500000}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
tip={longTip}
onSelectAll={jest.fn()}
/>,
)
// 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 = '<script>alert("xss")</script> & "quotes" \'apostrophes\''
// Act
render(
<Actions
{...defaultProps}
showSelect={true}
tip={specialTip}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
tip={unicodeTip}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={10}
onSelectAll={jest.fn()}
/>,
)
// Assert - should still render
const checkbox = container.querySelector('[class*="cursor-pointer"]')
expect(checkbox).toBeInTheDocument()
})
it('should handle negative selectedOptions', () => {
// Arrange & Act
const { container } = render(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={-1}
onSelectAll={jest.fn()}
/>,
)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={3}
onSelectAll={undefined}
/>,
)
// 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(<Actions {...defaultProps} />)
// 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(<Actions {...defaultProps} disabled={true} showSelect={false} />)
// 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(
<Actions
{...defaultProps}
disabled={true}
showSelect={true}
totalOptions={5}
selectedOptions={3}
onSelectAll={jest.fn()}
/>,
)
// 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(<Actions {...allProps} />)
// 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(<Actions handleNextStep={jest.fn()} />)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={totalOptions}
selectedOptions={selectedOptions}
onSelectAll={jest.fn()}
/>,
)
// 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(<Actions {...defaultProps} />)
// 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(<Actions {...defaultProps} />)
// 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(
<Actions
{...defaultProps}
showSelect={true}
totalOptions={5}
selectedOptions={3}
onSelectAll={jest.fn()}
/>,
)
// Assert - select all section should exist
const selectAllSection = container.querySelector('.flex.shrink-0.items-center')
expect(selectAllSection).toBeInTheDocument()
})
})
})

View File

@@ -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 }
}) => (
<div data-testid="document-picker">
<span data-testid="picker-value">{value?.name || 'No selection'}</span>
<select
data-testid="picker-select"
value={value?.id || ''}
onChange={(e) => {
const selected = files.find(f => f.id === e.target.value)
if (selected)
onChange(selected)
}}
>
{files.map(f => (
<option key={f.id} value={f.id}>{f.name}</option>
))}
</select>
</div>
),
}))
// Test data factories
const createMockLocalFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
name: 'test-file.pdf',
size: 1024,
type: 'application/pdf',
extension: 'pdf',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: jest.fn() as () => Promise<ArrayBuffer>,
bytes: jest.fn() as () => Promise<Uint8Array>,
slice: jest.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: jest.fn() as () => ReadableStream<Uint8Array>,
text: jest.fn() as () => Promise<string>,
...overrides,
} as CustomFile)
const createMockNotionPage = (overrides?: Partial<NotionPage>): 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>): CrawlResultItem => ({
title: 'Test Website',
markdown: 'Test content',
description: 'Test description',
source_url: 'https://example.com',
...overrides,
})
const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({
id: 'drive-file-1',
name: 'test-drive-file.docx',
size: 2048,
type: OnlineDriveFileType.file,
...overrides,
})
const createMockEstimateData = (overrides?: Partial<FileIndexingEstimateResponse>): 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(<ChunkPreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument()
})
it('should render document picker for local files', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.localFile} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render document picker for online documents', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.onlineDocument} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render document picker for website pages', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.websiteCrawl} />)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should render document picker for online drive files', () => {
render(<ChunkPreview {...defaultProps} dataSourceType={DatasourceType.onlineDrive} />)
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(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
// 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(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
// 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(<ChunkPreview {...defaultProps} isIdle={true} />)
// 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(<ChunkPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
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(<ChunkPreview {...defaultProps} isPending={true} />)
// 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(<ChunkPreview {...defaultProps} isPending={true} estimateData={estimateData} />)
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(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
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(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
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(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
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(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.localFile}
localFiles={localFiles}
handlePreviewFileChange={handlePreviewFileChange}
/>,
)
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(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDocument}
onlineDocuments={onlineDocuments}
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
/>,
)
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(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.websiteCrawl}
websitePages={websitePages}
handlePreviewWebsitePageChange={handlePreviewWebsitePageChange}
/>,
)
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(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDrive}
onlineDriveFiles={onlineDriveFiles}
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
/>,
)
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(<ChunkPreview {...defaultProps} estimateData={undefined} />)
expect(screen.queryByText('Chunk content')).not.toBeInTheDocument()
})
it('should handle empty preview array', () => {
mockDocForm.mockReturnValue(ChunkingMode.text)
const estimateData = createMockEstimateData({ preview: [] })
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.queryByText('Chunk content')).not.toBeInTheDocument()
})
it('should handle empty qa_preview array', () => {
mockDocForm.mockReturnValue(ChunkingMode.qa)
const estimateData = createMockEstimateData({ qa_preview: [] })
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
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(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
expect(screen.queryByText('Child')).not.toBeInTheDocument()
})
it('should handle badge showing 0 chunks', () => {
mockDocForm.mockReturnValue(ChunkingMode.text)
const estimateData = createMockEstimateData({ total_segments: 0 })
render(<ChunkPreview {...defaultProps} estimateData={estimateData} />)
// Badge with 0
expect(screen.getByText(/0/)).toBeInTheDocument()
})
it('should handle undefined online document properties', () => {
const onlineDocuments = [createMockNotionPage({ page_id: '', page_name: '' })]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDocument}
onlineDocuments={onlineDocuments}
/>,
)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should handle undefined website page properties', () => {
const websitePages = [createMockCrawlResult({ source_url: '', title: '' })]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.websiteCrawl}
websitePages={websitePages}
/>,
)
expect(screen.getByTestId('document-picker')).toBeInTheDocument()
})
it('should handle undefined online drive file properties', () => {
const onlineDriveFiles = [createMockOnlineDriveFile({ id: '', name: '' })]
render(
<ChunkPreview
{...defaultProps}
dataSourceType={DatasourceType.onlineDrive}
onlineDriveFiles={onlineDriveFiles}
/>,
)
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)')
})
})
})

View File

@@ -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>): File => ({
id: 'file-123',
name: 'test-document.pdf',
size: 2048,
type: 'application/pdf',
extension: 'pdf',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: jest.fn() as () => Promise<ArrayBuffer>,
bytes: jest.fn() as () => Promise<Uint8Array>,
slice: jest.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: jest.fn() as () => ReadableStream<Uint8Array>,
text: jest.fn() as () => Promise<string>,
...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(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} />)
// Real formatFileSize: 2048 bytes => "2.00 KB"
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
})
it('should render close button', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call useFilePreview with correct fileID', () => {
const file = createMockFile({ id: 'specific-file-id' })
render(<FilePreview {...defaultProps} file={file} />)
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(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} file={file} />)
// fileName = '', displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
it('should handle file without extension in name', () => {
const file = createMockFile({ name: 'noextension', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} />)
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(<FilePreview {...defaultProps} />)
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(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} hidePreview={hidePreview} />)
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(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('')
})
it('should handle empty extension', () => {
const file = createMockFile({ extension: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle zero file size', () => {
const file = createMockFile({ size: 0 })
render(<FilePreview {...defaultProps} file={file} />)
// 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(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 1000000 => "1M"
expect(screen.getByText(/1M/)).toBeInTheDocument()
})
it('should handle empty content', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(''),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// 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(<FilePreview {...defaultProps} file={file} />)
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(<FilePreview {...defaultProps} file={file} />)
// Same file should produce same result
rerender(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
})
})

View File

@@ -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>): 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(<OnlineDocumentPreview {...defaultProps} />)
// 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(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage} />)
expect(screen.getByText('database')).toBeInTheDocument()
})
it('should render close button', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
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(
<OnlineDocumentPreview
{...defaultProps}
currentPage={currentPage}
datasourceNodeId="node-789"
/>,
)
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(
<OnlineDocumentPreview {...defaultProps} currentPage={currentPage1} />,
)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
})
rerender(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage2} />)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledTimes(2)
})
})
it('should handle empty pipelineId', async () => {
mockPipelineId.mockReturnValue(undefined)
render(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
// 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(<OnlineDocumentPreview {...defaultProps} />)
// 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(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} hidePreview={hidePreview} />)
// 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(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage} />)
// 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(<OnlineDocumentPreview {...defaultProps} currentPage={currentPage} />)
expect(screen.getByText('database')).toBeInTheDocument()
})
it('should use credentialId from store', async () => {
mockGetState.mockReturnValue({
currentCredentialId: 'custom-credential',
})
render(<OnlineDocumentPreview {...defaultProps} />)
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(<OnlineDocumentPreview {...defaultProps} />)
// Content is empty, markdown area should still render but be empty
await waitFor(() => {
expect(screen.queryByText('Test content')).not.toBeInTheDocument()
})
})
})
})

View File

@@ -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>): 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(<WebsitePreview {...defaultProps} />)
// 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(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render close button', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the markdown content', () => {
render(<WebsitePreview {...defaultProps} />)
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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// 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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// 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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/1\.5M/)).toBeInTheDocument()
})
it('should show 0 characters for empty markdown', () => {
const currentWebsite = createMockCrawlResult({ markdown: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/0/)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = jest.fn()
render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />)
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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// 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 = '<script>alert("xss")</script> & < > " \''
const currentWebsite = createMockCrawlResult({ markdown: specialContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(specialContent)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
const currentWebsite = createMockCrawlResult({ title: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty source URL', () => {
const currentWebsite = createMockCrawlResult({ source_url: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long title', () => {
const longTitle = 'A'.repeat(500)
const currentWebsite = createMockCrawlResult({ title: longTitle })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle unicode characters in content', () => {
const unicodeContent = '你好世界 🌍 مرحبا こんにちは'
const currentWebsite = createMockCrawlResult({ markdown: unicodeContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
it('should handle URL with query parameters', () => {
const urlWithParams = 'https://example.com/page?query=test&param=value'
const currentWebsite = createMockCrawlResult({ source_url: urlWithParams })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
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(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithHash)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(<WebsitePreview {...defaultProps} />)
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(<WebsitePreview {...defaultProps} currentWebsite={website1} />)
expect(screen.getByText('Website 1')).toBeInTheDocument()
expect(screen.getByText('Content 1')).toBeInTheDocument()
rerender(<WebsitePreview {...defaultProps} currentWebsite={website2} />)
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(<WebsitePreview {...defaultProps} hidePreview={hidePreview1} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview1).toHaveBeenCalledTimes(1)
rerender(<WebsitePreview {...defaultProps} hidePreview={hidePreview2} />)
fireEvent.click(closeButton)
expect(hidePreview2).toHaveBeenCalledTimes(1)
expect(hidePreview1).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -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> = {}): 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(<Actions {...defaultActionsProps} />)
// 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(<Actions {...defaultActionsProps} />)
// 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(<Actions {...defaultActionsProps} />)
// 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(<Actions {...defaultActionsProps} />)
// 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(<Actions {...defaultActionsProps} runDisabled={false} />)
// 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(<Actions {...defaultActionsProps} runDisabled={true} />)
// 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(<Actions {...defaultActionsProps} runDisabled={undefined} />)
// 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(<Actions {...defaultActionsProps} onBack={onBack} />)
// 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(<Actions {...defaultActionsProps} onProcess={onProcess} />)
// 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(<Actions {...defaultActionsProps} onProcess={onProcess} runDisabled={true} />)
// 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(<Header {...defaultHeaderProps} />)
// Assert
expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should render reset button', () => {
// Arrange & Act
render(<Header {...defaultHeaderProps} />)
// Assert
expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument()
})
it('should render preview button with icon', () => {
// Arrange & Act
render(<Header {...defaultHeaderProps} />)
// 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(<Header {...defaultHeaderProps} />)
// Assert
expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should have correct container layout', () => {
// Arrange & Act
const { container } = render(<Header {...defaultHeaderProps} />)
// 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(<Header {...defaultHeaderProps} resetDisabled={false} />)
// 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(<Header {...defaultHeaderProps} resetDisabled={true} />)
// 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(<Header {...defaultHeaderProps} previewDisabled={false} />)
// 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(<Header {...defaultHeaderProps} previewDisabled={true} />)
// Assert
const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })
expect(previewButton).toBeDisabled()
})
})
it('should handle onPreview being undefined', () => {
// Arrange & Act
render(<Header {...defaultHeaderProps} onPreview={undefined} />)
// 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(<Header {...defaultHeaderProps} onReset={onReset} />)
// 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(<Header {...defaultHeaderProps} onReset={onReset} resetDisabled={true} />)
// 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(<Header {...defaultHeaderProps} onPreview={onPreview} />)
// 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(<Header {...defaultHeaderProps} onPreview={onPreview} previewDisabled={true} />)
// 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(<Header {...defaultHeaderProps} resetDisabled={true} previewDisabled={true} />)
// 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(<Header {...defaultHeaderProps} resetDisabled={false} previewDisabled={false} />)
// 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<unknown>,
isRunning: false,
}
beforeEach(() => {
jest.clearAllMocks()
toastNotifySpy.mockClear()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<Form {...defaultFormProps} />)
// Assert
expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should render form element', () => {
// Arrange & Act
const { container } = render(<Form {...defaultFormProps} />)
// Assert
const form = container.querySelector('form')
expect(form).toBeInTheDocument()
})
it('should render Header component', () => {
// Arrange & Act
render(<Form {...defaultFormProps} />)
// 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(<Form {...defaultFormProps} />)
// 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(<Form {...defaultFormProps} isRunning={true} />)
// 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(<Form {...defaultFormProps} isRunning={false} />)
// 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(<Form {...defaultFormProps} configurations={[]} />)
// 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(<Form {...defaultFormProps} configurations={configurations} initialData={{ var1: '', var2: '', var3: '' }} />)
// 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(<Form {...defaultFormProps} ref={mockRef} />)
// 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(<Form {...defaultFormProps} ref={mockRef} onSubmit={onSubmit} />)
// 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(<Form {...defaultFormProps} ref={mockRef} schema={failingSchema} />)
// 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(<Form {...defaultFormProps} onPreview={onPreview} />)
// 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(<Form {...defaultFormProps} onSubmit={onSubmit} />)
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(<Form {...defaultFormProps} />)
// 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(<Form {...defaultFormProps} configurations={configurations} />)
// 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(<Form {...defaultFormProps} configurations={configurations} initialData={initialData} />)
// 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(<Form {...defaultFormProps} configurations={configurations} initialData={initialData} />)
// 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(<Form {...defaultFormProps} schema={failingSchema} />)
// 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(<Form {...defaultFormProps} schema={failingSchema} onSubmit={onSubmit} />)
// 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(<Form {...defaultFormProps} schema={passingSchema} onSubmit={onSubmit} />)
// 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(<Form {...defaultFormProps} initialData={{}} />)
// 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(<Form {...defaultFormProps} configurations={configurations} initialData={{ text: '', number: 0 }} />)
// Assert
expect(screen.getByText('Text Field')).toBeInTheDocument()
expect(screen.getByText('Number Field')).toBeInTheDocument()
})
it('should handle null ref', () => {
// Arrange & Act
render(<Form {...defaultFormProps} ref={{ current: null }} />)
// 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(<Form {...defaultFormProps} configurations={configurations} />)
// 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(<Form {...defaultFormProps} configurations={configurations} />)
// 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<unknown>,
isRunning: false,
}
it('should render Header within Form', () => {
// Arrange & Act
render(<Form {...defaultFormProps} />)
// 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(<Form {...defaultFormProps} isRunning={true} />)
// Assert
const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })
expect(previewButton).toBeDisabled()
})
})
})

View File

@@ -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<string, unknown> = {}
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> = {}): 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<React.ComponentProps<typeof ProcessDocuments>> = {}) => ({
dataSourceNodeId: 'test-node-id',
ref: { current: null } as React.RefObject<unknown>,
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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// Assert
expect(useInputVariables).toHaveBeenCalledWith('custom-node-id')
})
it('should handle empty dataSourceNodeId', () => {
// Arrange
const props = createDefaultProps({ dataSourceNodeId: '' })
// Act
render(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
rerender(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
expect(useInputVariables).toHaveBeenLastCalledWith('node-1')
rerender(<ProcessDocuments {...props} dataSourceNodeId="node-2" />)
// 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(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should handle empty variables array', () => {
// Arrange
mockParamsConfig = { variables: [] }
mockConfigurations = []
const props = createDefaultProps()
// Act
render(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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(<ProcessDocuments {...props} />)
// 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()
})
})
})

View File

@@ -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 <img src={src} alt={alt} className={className} data-testid="next-image" />
},
}))
// Mock FieldInfo component
jest.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue, valueIcon }: { label: string; displayedValue: string; valueIcon?: React.ReactNode }) => (
<div data-testid="field-info" data-label={label}>
<span data-testid="field-label">{label}</span>
<span data-testid="field-value">{displayedValue}</span>
{valueIcon && <span data-testid="field-icon">{valueIcon}</span>}
</div>
),
}))
// 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> = {}): 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(<RuleDetail />)
// Assert
const fieldInfos = screen.getAllByTestId('field-info')
expect(fieldInfos).toHaveLength(3)
})
it('should render three FieldInfo components', () => {
// Arrange
const sourceData = createMockProcessRule()
// Act
render(
<RuleDetail
sourceData={sourceData}
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
// Assert
const fieldInfos = screen.getAllByTestId('field-info')
expect(fieldInfos).toHaveLength(3)
})
it('should render mode field with correct label', () => {
// Arrange & Act
render(<RuleDetail />)
// 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(<RuleDetail />)
// 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(<RuleDetail sourceData={sourceData} />)
// 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(<RuleDetail sourceData={sourceData} />)
// 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(<RuleDetail sourceData={sourceData} />)
// 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(<RuleDetail sourceData={sourceData} />)
// 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(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
// 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(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
// 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(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
// 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(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
// 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(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />)
// 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(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
// 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(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.fullText}
/>,
)
// 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(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.hybrid}
/>,
)
// 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(
<RuleDetail
indexingType={IndexingType.ECONOMICAL}
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
// 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(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
// 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(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.fullText}
/>,
)
// 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(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.hybrid}
/>,
)
// 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(<RuleDetail />)
// Assert
expect(screen.getAllByTestId('field-info')).toHaveLength(3)
})
it('should handle undefined indexingType with defined retrievalMethod', () => {
// Arrange & Act
render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.hybrid} />)
// 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(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
// 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(<RuleDetail sourceData={sourceData} />)
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(
<RuleDetail
sourceData={sourceData}
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
// 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(
<RuleDetail
sourceData={sourceData}
indexingType={IndexingType.ECONOMICAL}
retrievalMethod={RETRIEVE_METHOD.fullText}
/>,
)
// 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(<RuleDetail {...props} />)
rerender(<RuleDetail {...props} />)
// Assert - component renders correctly after rerender
expect(screen.getAllByTestId('field-info')).toHaveLength(3)
})
})
})

View File

@@ -39,7 +39,7 @@ const RuleDetail = ({
}, [sourceData, t])
return (
<div className='flex flex-col gap-1'>
<div className='flex flex-col gap-1' data-testid='rule-detail'>
<FieldInfo
label={t('datasetDocuments.embedding.mode')}
displayedValue={getValue('mode')}

View File

@@ -0,0 +1,808 @@
import { render, screen } from '@testing-library/react'
import React from 'react'
import Processing from './index'
import type { InitialDocumentDetail } from '@/models/pipeline'
import { DatasourceType } from '@/models/pipeline'
import type { DocumentIndexingStatus } from '@/models/datasets'
// ==========================================
// Mock External Dependencies
// ==========================================
// Mock react-i18next (handled by __mocks__/react-i18next.ts but we override for custom messages)
jest.mock('react-i18next', () => ({
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: <T,>(selector: (state: { dataset?: typeof mockDataset }) => T): T => {
return selector({ dataset: mockDataset })
},
}))
// Mock the EmbeddingProcess component to track props
let embeddingProcessProps: Record<string, unknown> = {}
jest.mock('./embedding-process', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
embeddingProcessProps = props
return (
<div data-testid="embedding-process">
<span data-testid="ep-dataset-id">{props.datasetId as string}</span>
<span data-testid="ep-batch-id">{props.batchId as string}</span>
<span data-testid="ep-documents-count">{(props.documents as unknown[])?.length ?? 0}</span>
<span data-testid="ep-indexing-type">{props.indexingType as string || 'undefined'}</span>
<span data-testid="ep-retrieval-method">{props.retrievalMethod as string || 'undefined'}</span>
</div>
)
},
}))
// ==========================================
// 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> = {}): 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(<Processing {...props} />)
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render the EmbeddingProcess component', () => {
// Arrange
const props = {
batchId: 'batch-456',
documents: createMockDocuments(3),
}
// Act
render(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// 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(<Processing {...props} />)
// Assert
expect(embeddingProcessProps.indexingType).toBe(technique)
})
})
})

View File

@@ -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 (
<div className='flex h-full w-full justify-center overflow-hidden'>

View File

@@ -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 }) => (

View File

@@ -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(),

View File

@@ -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),

View File

@@ -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: {
'^@/(.*)$': '<rootDir>/$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$': '<rootDir>/__mocks__/ky.ts',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader