mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
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:
71
web/__mocks__/ky.ts
Normal file
71
web/__mocks__/ky.ts
Normal 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 }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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¶m=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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user