test: add comprehensive unit and integration tests for dataset module (#32187)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Coding On Star
2026-02-12 10:00:32 +08:00
committed by GitHub
parent f953331f91
commit 10f85074e8
388 changed files with 22637 additions and 15567 deletions

View File

@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Header from '../header'
vi.mock('@/app/components/base/button', () => ({
default: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <span data-testid="divider" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
}))
vi.mock('../credential-selector', () => ({
default: () => <div data-testid="credential-selector" />,
}))
describe('Header', () => {
const defaultProps = {
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
onClickConfiguration: vi.fn(),
pluginName: 'TestPlugin',
credentials: [],
currentCredentialId: '',
onCredentialChange: vi.fn(),
}
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
it('should render credential selector', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('credential-selector')).toBeInTheDocument()
})
it('should link to external doc', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('Documentation').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
})

View File

@@ -1,8 +1,8 @@
import type { CredentialSelectorProps } from './index'
import type { CredentialSelectorProps } from '../index'
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import CredentialSelector from './index'
import CredentialSelector from '../index'
// Mock CredentialTypeEnum to avoid deep import chain issues
enum MockCredentialTypeEnum {
@@ -20,26 +20,25 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
// Mock portal-to-follow-elem - use React state to properly handle open/close
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: any) => {
const MockPortalToFollowElem = ({ children, open }: { children: React.ReactNode, open: boolean }) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child: any) => {
if (!child)
{React.Children.map(children, (child) => {
if (!React.isValidElement(child))
return null
// Pass open state to children via context-like prop cloning
return React.cloneElement(child, { __portalOpen: open })
return React.cloneElement(child as React.ReactElement<{ __portalOpen?: boolean }>, { __portalOpen: open })
})}
</div>
)
}
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => (
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string, __portalOpen?: boolean }) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => {
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: { children: React.ReactNode, className?: string, __portalOpen?: boolean }) => {
// Match actual behavior: returns null when not open
if (!__portalOpen)
return null
@@ -60,9 +59,6 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
// CredentialIcon - imported directly (not mocked)
// This is a simple UI component with no external dependencies
// ==========================================
// Test Data Builders
// ==========================================
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
@@ -94,38 +90,28 @@ describe('CredentialSelector', () => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests - Verify component renders correctly
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByTestId('portal-root')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should render current credential name in trigger', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should render credential icon with correct props', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<CredentialSelector {...props} />)
// Assert - CredentialIcon renders an img when avatarUrl is provided
@@ -135,30 +121,23 @@ describe('CredentialSelector', () => {
})
it('should render dropdown arrow icon', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<CredentialSelector {...props} />)
// Assert
const svgIcon = container.querySelector('svg')
expect(svgIcon).toBeInTheDocument()
})
it('should not render dropdown content initially', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should render all credentials in dropdown when opened', () => {
// Arrange
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
@@ -173,41 +152,30 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Props Testing - Verify all prop variations
// ==========================================
describe('Props', () => {
describe('currentCredentialId prop', () => {
it('should display first credential when currentCredentialId matches first', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should display second credential when currentCredentialId matches second', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should display third credential when currentCredentialId matches third', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-3' })
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Credential 3')).toBeInTheDocument()
})
@@ -216,41 +184,33 @@ describe('CredentialSelector', () => {
['cred-2', 'Credential 2'],
['cred-3', 'Credential 3'],
])('should display %s credential name when currentCredentialId is %s', (credId, expectedName) => {
// Arrange
const props = createDefaultProps({ currentCredentialId: credId })
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText(expectedName)).toBeInTheDocument()
})
})
describe('credentials prop', () => {
it('should render single credential correctly', () => {
// Arrange
const props = createDefaultProps({
credentials: [createMockCredential()],
currentCredentialId: 'cred-1',
})
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Test Credential')).toBeInTheDocument()
})
it('should render multiple credentials in dropdown', () => {
// Arrange
const props = createDefaultProps({
credentials: createMockCredentials(5),
currentCredentialId: 'cred-1',
})
render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
@@ -259,23 +219,19 @@ describe('CredentialSelector', () => {
})
it('should handle credentials with special characters in name', () => {
// Arrange
const props = createDefaultProps({
credentials: [createMockCredential({ id: 'cred-special', name: 'Test & Credential <special>' })],
currentCredentialId: 'cred-special',
})
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Test & Credential <special>')).toBeInTheDocument()
})
})
describe('onCredentialChange prop', () => {
it('should be called when selecting a credential', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
@@ -284,11 +240,9 @@ describe('CredentialSelector', () => {
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// Click on second credential
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
@@ -296,7 +250,6 @@ describe('CredentialSelector', () => {
['cred-2', 'Credential 2'],
['cred-3', 'Credential 3'],
])('should call onCredentialChange with %s when selecting %s', (credId, credentialName) => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
@@ -310,7 +263,6 @@ describe('CredentialSelector', () => {
const credentialOption = within(portalContent).getByText(credentialName)
fireEvent.click(credentialOption)
// Assert
expect(mockOnChange).toHaveBeenCalledWith(credId)
})
@@ -330,18 +282,14 @@ describe('CredentialSelector', () => {
const credential1 = screen.getByText('Credential 1')
fireEvent.click(credential1)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-1')
})
})
})
// ==========================================
// User Interactions - Test event handlers
// ==========================================
describe('User Interactions', () => {
it('should toggle dropdown open when trigger is clicked', () => {
// Arrange
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
@@ -357,24 +305,20 @@ describe('CredentialSelector', () => {
})
it('should call onCredentialChange when clicking a credential item', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
it('should close dropdown after selecting a credential', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
@@ -393,7 +337,6 @@ describe('CredentialSelector', () => {
})
it('should handle rapid consecutive clicks on trigger', () => {
// Arrange
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
@@ -428,19 +371,15 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Side Effects and Cleanup - Test useEffect behavior
// ==========================================
describe('Side Effects and Cleanup', () => {
it('should auto-select first credential when currentCredential is not found and credentials exist', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'non-existent-id',
onCredentialChange: mockOnChange,
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should auto-select first credential
@@ -448,14 +387,12 @@ describe('CredentialSelector', () => {
})
it('should not call onCredentialChange when currentCredential is found', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'cred-2',
onCredentialChange: mockOnChange,
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should not auto-select
@@ -463,7 +400,6 @@ describe('CredentialSelector', () => {
})
it('should not call onCredentialChange when credentials array is empty', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'cred-1',
@@ -471,7 +407,6 @@ describe('CredentialSelector', () => {
onCredentialChange: mockOnChange,
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should not call since no credentials to select
@@ -479,7 +414,6 @@ describe('CredentialSelector', () => {
})
it('should auto-select when credentials change and currentCredential becomes invalid', async () => {
// Arrange
const mockOnChange = vi.fn()
const initialCredentials = createMockCredentials(3)
const props = createDefaultProps({
@@ -510,7 +444,6 @@ describe('CredentialSelector', () => {
})
it('should not trigger auto-select effect on every render with same props', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
@@ -524,12 +457,9 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Callback Stability and Memoization - Test useCallback behavior
// ==========================================
describe('Callback Stability and Memoization', () => {
it('should have stable handleCredentialChange callback', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
@@ -545,7 +475,6 @@ describe('CredentialSelector', () => {
})
it('should update handleCredentialChange when onCredentialChange changes', () => {
// Arrange
const mockOnChange1 = vi.fn()
const mockOnChange2 = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange1 })
@@ -567,15 +496,11 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Memoization Logic and Dependencies - Test useMemo behavior
// ==========================================
describe('Memoization Logic and Dependencies', () => {
it('should find currentCredential by id', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<CredentialSelector {...props} />)
// Assert - Should display credential 2
@@ -583,7 +508,6 @@ describe('CredentialSelector', () => {
})
it('should update currentCredential when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<CredentialSelector {...props} />)
@@ -598,7 +522,6 @@ describe('CredentialSelector', () => {
})
it('should update currentCredential when credentials array changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<CredentialSelector {...props} />)
@@ -616,14 +539,12 @@ describe('CredentialSelector', () => {
})
it('should return undefined currentCredential when id not found', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'non-existent',
onCredentialChange: mockOnChange,
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should trigger auto-select effect
@@ -631,17 +552,13 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Component Memoization - Test React.memo behavior
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(CredentialSelector.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
const renderSpy = vi.fn()
@@ -652,7 +569,6 @@ describe('CredentialSelector', () => {
}
const MemoizedTracked = React.memo(TrackedCredentialSelector)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
@@ -661,22 +577,18 @@ describe('CredentialSelector', () => {
})
it('should re-render when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<CredentialSelector {...props} />)
// Assert initial
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act
rerender(<CredentialSelector {...props} currentCredentialId="cred-2" />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should re-render when credentials array reference changes', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<CredentialSelector {...props} />)
@@ -686,12 +598,10 @@ describe('CredentialSelector', () => {
]
rerender(<CredentialSelector {...props} credentials={newCredentials} />)
// Assert
expect(screen.getByText('New Name 1')).toBeInTheDocument()
})
it('should re-render when onCredentialChange reference changes', () => {
// Arrange
const mockOnChange1 = vi.fn()
const mockOnChange2 = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange1 })
@@ -711,18 +621,13 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle empty credentials array', () => {
// Arrange
const props = createDefaultProps({
credentials: [],
currentCredentialId: 'cred-1',
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should render without crashing
@@ -730,7 +635,6 @@ describe('CredentialSelector', () => {
})
it('should handle undefined avatar_url in credential', () => {
// Arrange
const credentialWithoutAvatar = createMockCredential({
id: 'cred-no-avatar',
name: 'No Avatar Credential',
@@ -741,7 +645,6 @@ describe('CredentialSelector', () => {
currentCredentialId: 'cred-no-avatar',
})
// Act
const { container } = render(<CredentialSelector {...props} />)
// Assert - Should render without crashing and show first letter fallback
@@ -754,7 +657,6 @@ describe('CredentialSelector', () => {
})
it('should handle empty string name in credential', () => {
// Arrange
const credentialWithEmptyName = createMockCredential({
id: 'cred-empty-name',
name: '',
@@ -764,7 +666,6 @@ describe('CredentialSelector', () => {
currentCredentialId: 'cred-empty-name',
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should render without crashing
@@ -772,7 +673,6 @@ describe('CredentialSelector', () => {
})
it('should handle very long credential name', () => {
// Arrange
const longName = 'A'.repeat(200)
const credentialWithLongName = createMockCredential({
id: 'cred-long-name',
@@ -783,15 +683,12 @@ describe('CredentialSelector', () => {
currentCredentialId: 'cred-long-name',
})
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in credential name', () => {
// Arrange
const specialName = '测试 Credential <script>alert("xss")</script> & "quoted"'
const credentialWithSpecialName = createMockCredential({
id: 'cred-special',
@@ -802,15 +699,12 @@ describe('CredentialSelector', () => {
currentCredentialId: 'cred-special',
})
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle numeric id as string', () => {
// Arrange
const credentialWithNumericId = createMockCredential({
id: '123456',
name: 'Numeric ID Credential',
@@ -820,30 +714,24 @@ describe('CredentialSelector', () => {
currentCredentialId: '123456',
})
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Numeric ID Credential')).toBeInTheDocument()
})
it('should handle large number of credentials', () => {
// Arrange
const manyCredentials = createMockCredentials(100)
const props = createDefaultProps({
credentials: manyCredentials,
currentCredentialId: 'cred-50',
})
// Act
render(<CredentialSelector {...props} />)
// Assert
expect(screen.getByText('Credential 50')).toBeInTheDocument()
})
it('should handle credential selection with duplicate names', () => {
// Arrange
const mockOnChange = vi.fn()
const duplicateCredentials = [
createMockCredential({ id: 'cred-1', name: 'Same Name' }),
@@ -855,7 +743,6 @@ describe('CredentialSelector', () => {
onCredentialChange: mockOnChange,
})
// Act
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
@@ -865,7 +752,6 @@ describe('CredentialSelector', () => {
const sameNameElements = screen.getAllByText('Same Name')
expect(sameNameElements.length).toBe(3)
// Click the last dropdown item (cred-2 in dropdown)
fireEvent.click(sameNameElements[2])
// Assert - Should call with the correct id even with duplicate names
@@ -873,12 +759,10 @@ describe('CredentialSelector', () => {
})
it('should not crash when clicking credential after unmount', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
const { unmount } = render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
@@ -891,7 +775,6 @@ describe('CredentialSelector', () => {
})
it('should handle whitespace-only credential name', () => {
// Arrange
const credentialWithWhitespace = createMockCredential({
id: 'cred-whitespace',
name: ' ',
@@ -901,7 +784,6 @@ describe('CredentialSelector', () => {
currentCredentialId: 'cred-whitespace',
})
// Act
render(<CredentialSelector {...props} />)
// Assert - Should render without crashing
@@ -909,58 +791,43 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Styling and CSS Classes
// ==========================================
describe('Styling', () => {
it('should apply overflow-hidden class to trigger', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<CredentialSelector {...props} />)
// Assert
const trigger = screen.getByTestId('portal-trigger')
expect(trigger).toHaveClass('overflow-hidden')
})
it('should apply grow class to trigger', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<CredentialSelector {...props} />)
// Assert
const trigger = screen.getByTestId('portal-trigger')
expect(trigger).toHaveClass('grow')
})
it('should apply z-10 class to dropdown content', () => {
// Arrange
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// Assert
const content = screen.getByTestId('portal-content')
expect(content).toHaveClass('z-10')
})
})
// ==========================================
// Integration with Child Components
// ==========================================
describe('Integration with Child Components', () => {
it('should pass currentCredential to Trigger component', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<CredentialSelector {...props} />)
// Assert - Trigger should display the correct credential
@@ -968,7 +835,6 @@ describe('CredentialSelector', () => {
})
it('should pass isOpen state to Trigger component', () => {
// Arrange
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
@@ -985,11 +851,9 @@ describe('CredentialSelector', () => {
})
it('should pass credentials to List component', () => {
// Arrange
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
@@ -1000,11 +864,9 @@ describe('CredentialSelector', () => {
})
it('should pass currentCredentialId to List component', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
@@ -1015,12 +877,10 @@ describe('CredentialSelector', () => {
})
it('should pass handleCredentialChange to List component', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
// Act
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
const credential3 = screen.getByText('Credential 3')
@@ -1031,9 +891,7 @@ describe('CredentialSelector', () => {
})
})
// ==========================================
// Portal Configuration
// ==========================================
describe('Portal Configuration', () => {
it('should configure PortalToFollowElem with placement bottom-start', () => {
// This test verifies the portal is configured correctly

View File

@@ -0,0 +1,32 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from '../item'
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorItem', () => {
const defaultProps = {
credential: { id: 'cred-1', name: 'My Account', avatar_url: 'https://example.com/avatar.png' } as DataSourceCredential,
isSelected: false,
onCredentialChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render credential name and icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('My Account')).toBeInTheDocument()
expect(screen.getByTestId('credential-icon')).toBeInTheDocument()
})
it('should call onCredentialChange with credential id on click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('My Account'))
expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-1')
})
})

View File

@@ -0,0 +1,37 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from '../list'
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorList', () => {
const mockCredentials: DataSourceCredential[] = [
{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential,
{ id: 'cred-2', name: 'Account B', avatar_url: '' } as DataSourceCredential,
]
const defaultProps = {
currentCredentialId: 'cred-1',
credentials: mockCredentials,
onCredentialChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all credentials', () => {
render(<List {...defaultProps} />)
expect(screen.getByText('Account A')).toBeInTheDocument()
expect(screen.getByText('Account B')).toBeInTheDocument()
})
it('should call onCredentialChange on item click', () => {
render(<List {...defaultProps} />)
fireEvent.click(screen.getByText('Account B'))
expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-2')
})
})

View File

@@ -0,0 +1,36 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Trigger from '../trigger'
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorTrigger', () => {
it('should render credential name when provided', () => {
render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential}
isOpen={false}
/>,
)
expect(screen.getByText('Account A')).toBeInTheDocument()
})
it('should render empty name when no credential', () => {
render(<Trigger currentCredential={undefined} isOpen={false} />)
expect(screen.getByTestId('credential-icon')).toBeInTheDocument()
})
it('should apply hover style when open', () => {
const { container } = render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'A', avatar_url: '' } as DataSourceCredential}
isOpen={true}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-state-base-hover')
})
})

View File

@@ -1,658 +0,0 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from './header'
// Mock CredentialTypeEnum to avoid deep import chain issues
enum MockCredentialTypeEnum {
OAUTH2 = 'oauth2',
API_KEY = 'api_key',
}
// Mock plugin-auth module to avoid deep import chain issues
vi.mock('@/app/components/plugins/plugin-auth', () => ({
CredentialTypeEnum: {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
},
}))
// Mock portal-to-follow-elem - required for CredentialSelector
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: any) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child: any) => {
if (!child)
return null
return React.cloneElement(child, { __portalOpen: open })
})}
</div>
)
}
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => {
if (!__portalOpen)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
}
return {
PortalToFollowElem: MockPortalToFollowElem,
PortalToFollowElemTrigger: MockPortalToFollowElemTrigger,
PortalToFollowElemContent: MockPortalToFollowElemContent,
}
})
// ==========================================
// Test Data Builders
// ==========================================
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: { key: 'value' },
is_default: false,
type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'],
...overrides,
})
const createMockCredentials = (count: number = 3): DataSourceCredential[] =>
Array.from({ length: count }, (_, i) =>
createMockCredential({
id: `cred-${i + 1}`,
name: `Credential ${i + 1}`,
avatar_url: `https://example.com/avatar-${i + 1}.png`,
is_default: i === 0,
}))
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
pluginName: 'Test Plugin',
currentCredentialId: 'cred-1',
onCredentialChange: vi.fn(),
credentials: createMockCredentials(),
...overrides,
})
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
it('should render documentation link with correct attributes', () => {
// Arrange
const props = createDefaultProps({
docTitle: 'API Docs',
docLink: 'https://api.example.com/docs',
})
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /API Docs/i })
expect(link).toHaveAttribute('href', 'https://api.example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render document title with title attribute', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'My Documentation' })
// Act
render(<Header {...props} />)
// Assert
const titleSpan = screen.getByText('My Documentation')
expect(titleSpan).toHaveAttribute('title', 'My Documentation')
})
it('should render CredentialSelector with correct props', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - CredentialSelector should render current credential name
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should render configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render book icon in documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - RiBookOpenLine renders as SVG
const link = screen.getByRole('link')
const svg = link.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render divider between credential selector and configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Divider component should be rendered
// Divider typically renders as a div with specific styling
const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5')
expect(divider).toBeInTheDocument()
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('docTitle prop', () => {
it('should display the document title', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Getting Started Guide' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Getting Started Guide')).toBeInTheDocument()
})
it.each([
'Quick Start',
'API Reference',
'Configuration Guide',
'Plugin Documentation',
])('should display "%s" as document title', (title) => {
// Arrange
const props = createDefaultProps({ docTitle: title })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(title)).toBeInTheDocument()
})
})
describe('docLink prop', () => {
it('should set correct href on documentation link', () => {
// Arrange
const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide')
})
it.each([
'https://docs.dify.ai',
'https://example.com/api',
'/local/docs',
])('should accept "%s" as docLink', (link) => {
// Arrange
const props = createDefaultProps({ docLink: link })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('link')).toHaveAttribute('href', link)
})
})
describe('pluginName prop', () => {
it('should pass pluginName to translation function', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'MyPlugin' })
// Act
render(<Header {...props} />)
// Assert - The translation mock returns the key with options
// Tooltip uses the translated content
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('onClickConfiguration prop', () => {
it('should call onClickConfiguration when configuration icon is clicked', () => {
// Arrange
const mockOnClick = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnClick })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
// The button contains the RiEqualizer2Line icon with onClick handler
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should not crash when onClickConfiguration is undefined', () => {
// Arrange
const props = createDefaultProps({ onClickConfiguration: undefined })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert - Component should still be rendered (no crash)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('CredentialSelector props passthrough', () => {
it('should pass currentCredentialId to CredentialSelector', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<Header {...props} />)
// Assert - Should display the second credential
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should pass credentials to CredentialSelector', () => {
// Arrange
const customCredentials = [
createMockCredential({ id: 'custom-1', name: 'Custom Credential' }),
]
const props = createDefaultProps({
credentials: customCredentials,
currentCredentialId: 'custom-1',
})
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Custom Credential')).toBeInTheDocument()
})
it('should pass onCredentialChange to CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown and select a credential
// Use getAllByTestId and select the first one (CredentialSelector's trigger)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
})
})
// ==========================================
// User Interactions
// ==========================================
describe('User Interactions', () => {
it('should open external link in new tab when clicking documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - Link has target="_blank" for new tab
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should allow credential selection through CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown (use first trigger which is CredentialSelector's)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
// Assert - Dropdown should be open
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should trigger configuration callback when clicking config icon', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnConfig })
const { container } = render(<Header {...props} />)
// Act
const configIcon = container.querySelector('.h-4.w-4')
fireEvent.click(configIcon!)
// Assert
expect(mockOnConfig).toHaveBeenCalled()
})
})
// ==========================================
// Component Memoization
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(Header.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const props = createDefaultProps()
const renderSpy = vi.fn()
const TrackedHeader: React.FC<HeaderProps> = (trackedProps) => {
renderSpy()
return <Header {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedHeader)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when docTitle changes', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Original Title' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Original Title')).toBeInTheDocument()
// Act
rerender(<Header {...props} docTitle="Updated Title" />)
// Assert
expect(screen.getByText('Updated Title')).toBeInTheDocument()
})
it('should re-render when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act
rerender(<Header {...props} currentCredentialId="cred-2" />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle empty docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle very long docTitle', () => {
// Arrange
const longTitle = 'A'.repeat(200)
const props = createDefaultProps({ docTitle: longTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle special characters in docTitle', () => {
// Arrange
const specialTitle = 'Docs & Guide <v2> "Special"'
const props = createDefaultProps({ docTitle: specialTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
// Arrange
const props = createDefaultProps({
credentials: [],
currentCredentialId: '',
})
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should handle special characters in pluginName', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'Plugin & Tool <v1>' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle unicode characters in docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '文档说明 📚' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('文档说明 📚')).toBeInTheDocument()
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct classes to container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2')
})
it('should apply correct classes to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('system-xs-medium', 'text-text-accent')
})
it('should apply shrink-0 to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('shrink-0')
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should work with full credential workflow', () => {
// Arrange
const mockOnCredentialChange = vi.fn()
const props = createDefaultProps({
onCredentialChange: mockOnCredentialChange,
currentCredentialId: 'cred-1',
})
render(<Header {...props} />)
// Assert initial state
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act - Open dropdown and select different credential
// Use first trigger which is CredentialSelector's
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3')
})
it('should display all components together correctly', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({
docTitle: 'Integration Test Docs',
docLink: 'https://test.com/docs',
pluginName: 'TestPlugin',
onClickConfiguration: mockOnConfig,
})
// Act
render(<Header {...props} />)
// Assert - All main elements present
expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector
expect(screen.getByRole('button')).toBeInTheDocument() // Config button
expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs')
})
})
// ==========================================
// Accessibility
// ==========================================
describe('Accessibility', () => {
it('should have accessible link', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Accessible Docs' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /Accessible Docs/i })
expect(link).toBeInTheDocument()
})
it('should have accessible button for configuration', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have noopener noreferrer for security on external links', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})

View File

@@ -1,21 +1,15 @@
import type { FileItem } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LocalFile from './index'
import LocalFile from '../index'
// Mock the hook
const mockUseLocalFileUpload = vi.fn()
vi.mock('./hooks/use-local-file-upload', () => ({
vi.mock('../hooks/use-local-file-upload', () => ({
useLocalFileUpload: (...args: unknown[]) => mockUseLocalFileUpload(...args),
}))
// Mock react-i18next for sub-components
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock theme hook for sub-components
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),

View File

@@ -1,9 +1,9 @@
import type { FileListItemProps } from './file-list-item'
import type { FileListItemProps } from '../file-list-item'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import FileListItem from './file-list-item'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
import FileListItem from '../file-list-item'
// Mock theme hook - can be changed per test
let mockTheme = 'light'

View File

@@ -1,33 +1,12 @@
import type { RefObject } from 'react'
import type { UploadDropzoneProps } from './upload-dropzone'
import type { UploadDropzoneProps } from '../upload-dropzone'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UploadDropzone from './upload-dropzone'
import UploadDropzone from '../upload-dropzone'
// Helper to create mock ref objects for testing
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
const translations: Record<string, string> = {
'stepOne.uploader.button': 'Drag and drop files, or',
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
'stepOne.uploader.browse': 'Browse',
'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total',
}
let result = translations[key] || key
if (options && typeof options === 'object') {
Object.entries(options).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v))
})
}
return result
},
}),
}))
describe('UploadDropzone', () => {
const defaultProps: UploadDropzoneProps = {
dropRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
@@ -78,20 +57,19 @@ describe('UploadDropzone', () => {
it('should render browse label when extensions are allowed', () => {
render(<UploadDropzone {...defaultProps} />)
expect(screen.getByText('Browse')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
})
it('should not render browse label when no extensions allowed', () => {
render(<UploadDropzone {...defaultProps} allowedExtensions={[]} />)
expect(screen.queryByText('Browse')).not.toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument()
})
it('should render file size and count limits', () => {
render(<UploadDropzone {...defaultProps} />)
const tipText = screen.getByText(/Supports.*Max.*15MB/i)
expect(tipText).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/)).toBeInTheDocument()
})
})
@@ -122,13 +100,13 @@ describe('UploadDropzone', () => {
it('should show batch upload text when supportBatchUpload is true', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument()
})
it('should show single file text when supportBatchUpload is false', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument()
})
})
@@ -161,7 +139,7 @@ describe('UploadDropzone', () => {
const onSelectFile = vi.fn()
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
const browseLabel = screen.getByText('Browse')
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
fireEvent.click(browseLabel)
expect(onSelectFile).toHaveBeenCalledTimes(1)
@@ -215,7 +193,7 @@ describe('UploadDropzone', () => {
it('should have cursor-pointer on browse label', () => {
render(<UploadDropzone {...defaultProps} />)
const browseLabel = screen.getByText('Browse')
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
expect(browseLabel).toHaveClass('cursor-pointer')
})
})

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { CustomFile, FileItem } from '@/models/datasets'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
// Mock notify function - defined before mocks
const mockNotify = vi.fn()
@@ -32,12 +32,6 @@ vi.mock('@/utils/format', () => ({
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock locale context
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
@@ -48,7 +42,6 @@ vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans'],
}))
// Mock config
vi.mock('@/config', () => ({
IS_CE_EDITION: false,
}))
@@ -62,7 +55,7 @@ const mockGetState = vi.fn(() => ({
}))
const mockStore = { getState: mockGetState }
vi.mock('../../store', () => ({
vi.mock('../../../store', () => ({
useDataSourceStoreWithSelector: vi.fn((selector: (state: { localFileList: FileItem[] }) => FileItem[]) =>
selector({ localFileList: [] }),
),
@@ -93,7 +86,7 @@ vi.mock('@/service/base', () => ({
}))
// Import after all mocks are set up
const { useLocalFileUpload } = await import('./use-local-file-upload')
const { useLocalFileUpload } = await import('../use-local-file-upload')
const { ToastContext } = await import('@/app/components/base/toast')
const createWrapper = () => {
@@ -728,7 +721,7 @@ describe('useLocalFileUpload', () => {
describe('file upload limit', () => {
it('should reject files exceeding total file upload limit', async () => {
// Mock store to return existing files
const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../store'))
const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../../store'))
const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({
fileID: `existing-${i}`,
file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile,

View File

@@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Title from '../title'
describe('OnlineDocumentTitle', () => {
it('should render title with name prop', () => {
render(<Title name="Notion Workspace" />)
expect(screen.getByText('datasetPipeline.onlineDocument.pageSelectorTitle:{"name":"Notion Workspace"}')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,100 @@
import type { NotionPageTreeItem, NotionPageTreeMap } from '../index'
import type { DataSourceNotionPageMap } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { recursivePushInParentDescendants } from '../utils'
const makePageEntry = (overrides: Partial<NotionPageTreeItem>): NotionPageTreeItem => ({
page_icon: null,
page_id: '',
page_name: '',
parent_id: '',
type: 'page',
is_bound: false,
children: new Set(),
descendants: new Set(),
depth: 0,
ancestors: [],
...overrides,
})
describe('recursivePushInParentDescendants', () => {
it('should add child to parent descendants', () => {
const pagesMap = {
parent1: { page_id: 'parent1', parent_id: 'root', page_name: 'Parent' },
child1: { page_id: 'child1', parent_id: 'parent1', page_name: 'Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
child1: makePageEntry({ page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child1, listTreeMap.child1)
expect(listTreeMap.parent1).toBeDefined()
expect(listTreeMap.parent1.children.has('child1')).toBe(true)
expect(listTreeMap.parent1.descendants.has('child1')).toBe(true)
})
it('should recursively populate ancestors for deeply nested items', () => {
const pagesMap = {
grandparent: { page_id: 'grandparent', parent_id: 'root', page_name: 'Grandparent' },
parent: { page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' },
child: { page_id: 'child', parent_id: 'parent', page_name: 'Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
parent: makePageEntry({ page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }),
child: makePageEntry({ page_id: 'child', parent_id: 'parent', page_name: 'Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child, listTreeMap.child)
expect(listTreeMap.child.depth).toBe(2)
expect(listTreeMap.child.ancestors).toContain('Grandparent')
expect(listTreeMap.child.ancestors).toContain('Parent')
})
it('should do nothing for root parent', () => {
const pagesMap = {
root_child: { page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
root_child: makePageEntry({ page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.root_child, listTreeMap.root_child)
// No new entries should be added since parent is root
expect(Object.keys(listTreeMap)).toEqual(['root_child'])
})
it('should handle missing parent_id gracefully', () => {
const pagesMap = {} as DataSourceNotionPageMap
const current = makePageEntry({ page_id: 'orphan', parent_id: undefined as unknown as string })
const listTreeMap: NotionPageTreeMap = { orphan: current }
// Should not throw
recursivePushInParentDescendants(pagesMap, listTreeMap, current, current)
expect(listTreeMap.orphan.depth).toBe(0)
})
it('should add to existing parent entry when parent already in tree', () => {
const pagesMap = {
parent: { page_id: 'parent', parent_id: 'root', page_name: 'Parent' },
child1: { page_id: 'child1', parent_id: 'parent', page_name: 'Child1' },
child2: { page_id: 'child2', parent_id: 'parent', page_name: 'Child2' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
parent: makePageEntry({ page_id: 'parent', parent_id: 'root', children: new Set(['child1']), descendants: new Set(['child1']), page_name: 'Parent' }),
child2: makePageEntry({ page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child2, listTreeMap.child2)
expect(listTreeMap.parent.children.has('child2')).toBe(true)
expect(listTreeMap.parent.descendants.has('child2')).toBe(true)
expect(listTreeMap.parent.children.has('child1')).toBe(true)
})
})

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from '../header'
describe('OnlineDriveHeader', () => {
const defaultProps = {
docTitle: 'S3 Guide',
docLink: 'https://docs.aws.com/s3',
onClickConfiguration: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('S3 Guide').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.aws.com/s3')
expect(link).toHaveAttribute('target', '_blank')
})
})

View File

@@ -0,0 +1,105 @@
import type { OnlineDriveData } from '@/types/pipeline'
import { describe, expect, it } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import { convertOnlineDriveData, isBucketListInitiation, isFile } from '../utils'
describe('online-drive utils', () => {
describe('isFile', () => {
it('should return true for file type', () => {
expect(isFile('file')).toBe(true)
})
it('should return false for folder type', () => {
expect(isFile('folder')).toBe(false)
})
})
describe('isBucketListInitiation', () => {
it('should return true when data has buckets and no prefix/bucket set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
{ bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], '')).toBe(true)
})
it('should return false when bucket is already set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], 'bucket-1')).toBe(false)
})
it('should return false when prefix is set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, ['folder/'], '')).toBe(false)
})
it('should return false when single bucket has files', () => {
const data = [
{
bucket: 'bucket-1',
files: [{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }],
is_truncated: false,
next_page_parameters: {},
},
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], '')).toBe(false)
})
})
describe('convertOnlineDriveData', () => {
it('should return empty result for empty data', () => {
const result = convertOnlineDriveData([], [], '')
expect(result.fileList).toEqual([])
expect(result.isTruncated).toBe(false)
expect(result.hasBucket).toBe(false)
})
it('should convert bucket list initiation to bucket items', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
{ bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
const result = convertOnlineDriveData(data, [], '')
expect(result.fileList).toHaveLength(2)
expect(result.fileList[0]).toEqual({
id: 'bucket-1',
name: 'bucket-1',
type: OnlineDriveFileType.bucket,
})
expect(result.hasBucket).toBe(true)
})
it('should convert files when not bucket list', () => {
const data = [
{
bucket: 'bucket-1',
files: [
{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const },
{ id: 'f2', name: 'folder', size: 0, type: 'folder' as const },
],
is_truncated: true,
next_page_parameters: { token: 'next' },
},
] as OnlineDriveData[]
const result = convertOnlineDriveData(data, [], 'bucket-1')
expect(result.fileList).toHaveLength(2)
expect(result.fileList[0].type).toBe(OnlineDriveFileType.file)
expect(result.fileList[0].size).toBe(100)
expect(result.fileList[1].type).toBe(OnlineDriveFileType.folder)
expect(result.fileList[1].size).toBeUndefined()
expect(result.isTruncated).toBe(true)
expect(result.nextPageParameters).toEqual({ token: 'next' })
expect(result.hasBucket).toBe(true)
})
})
})

View File

@@ -1,22 +1,13 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { fireEvent, render, screen } from '@testing-library/react'
import Connect from './index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
import Connect from '../index'
// Mock useToolIcon - hook has complex dependencies (API calls, stores)
const mockUseToolIcon = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (data: any) => mockUseToolIcon(data),
useToolIcon: (data: DataSourceNodeType) => mockUseToolIcon(data),
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
title: 'Test Node',
plugin_id: 'plugin-123',
@@ -37,9 +28,6 @@ const createDefaultProps = (overrides?: Partial<ConnectProps>): ConnectProps =>
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('Connect', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -48,15 +36,10 @@ describe('Connect', () => {
mockUseToolIcon.mockReturnValue('https://example.com/icon.png')
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Component should render with connect button
@@ -64,10 +47,8 @@ describe('Connect', () => {
})
it('should render the BlockIcon component', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Connect {...props} />)
// Assert - BlockIcon container should exist
@@ -76,12 +57,10 @@ describe('Connect', () => {
})
it('should render the not connected message with node title', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'My Google Drive' }),
})
// Act
render(<Connect {...props} />)
// Assert - Should show translation key with interpolated name (use getAllBy since both messages contain similar text)
@@ -90,10 +69,8 @@ describe('Connect', () => {
})
it('should render the not connected tip message', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should show tip translation key
@@ -101,10 +78,8 @@ describe('Connect', () => {
})
it('should render the connect button with correct text', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Button should have connect text
@@ -113,10 +88,8 @@ describe('Connect', () => {
})
it('should render with primary button variant', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Button should be primary variant
@@ -125,10 +98,8 @@ describe('Connect', () => {
})
it('should render Icon3Dots component', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Connect {...props} />)
// Assert - Icon3Dots should be rendered (it's an SVG element)
@@ -137,10 +108,8 @@ describe('Connect', () => {
})
it('should apply correct container styling', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Connect {...props} />)
// Assert - Container should have expected classes
@@ -149,30 +118,22 @@ describe('Connect', () => {
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('nodeData prop', () => {
it('should pass nodeData to useToolIcon hook', () => {
// Arrange
const nodeData = createMockNodeData({ plugin_id: 'my-plugin' })
const props = createDefaultProps({ nodeData })
// Act
render(<Connect {...props} />)
// Assert
expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData)
})
it('should display node title in not connected message', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'Dropbox Storage' }),
})
// Act
render(<Connect {...props} />)
// Assert - Translation key should be in document (mock returns key)
@@ -181,12 +142,10 @@ describe('Connect', () => {
})
it('should display node title in tip message', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'OneDrive Connector' }),
})
// Act
render(<Connect {...props} />)
// Assert - Translation key should be in document
@@ -200,12 +159,10 @@ describe('Connect', () => {
{ title: 'Amazon S3' },
{ title: '' },
])('should handle nodeData with title=$title', ({ title }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title }),
})
// Act
render(<Connect {...props} />)
// Assert - Should render without error
@@ -215,24 +172,19 @@ describe('Connect', () => {
describe('onSetting prop', () => {
it('should call onSetting when connect button is clicked', () => {
// Arrange
const mockOnSetting = vi.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
// Act
render(<Connect {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSetting).toHaveBeenCalledTimes(1)
})
it('should call onSetting when button clicked', () => {
// Arrange
const mockOnSetting = vi.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
// Act
render(<Connect {...props} />)
fireEvent.click(screen.getByRole('button'))
@@ -242,60 +194,47 @@ describe('Connect', () => {
})
it('should call onSetting on each button click', () => {
// Arrange
const mockOnSetting = vi.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
// Act
render(<Connect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockOnSetting).toHaveBeenCalledTimes(3)
})
})
})
// ==========================================
// User Interactions and Event Handlers
// ==========================================
describe('User Interactions', () => {
describe('Connect Button', () => {
it('should trigger onSetting callback on click', () => {
// Arrange
const mockOnSetting = vi.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
render(<Connect {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSetting).toHaveBeenCalled()
})
it('should be interactive and focusable', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
const button = screen.getByRole('button')
// Assert
expect(button).not.toBeDisabled()
})
it('should handle keyboard interaction (Enter key)', () => {
// Arrange
const mockOnSetting = vi.fn()
const props = createDefaultProps({ onSetting: mockOnSetting })
render(<Connect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.keyDown(button, { key: 'Enter' })
@@ -305,29 +244,22 @@ describe('Connect', () => {
})
})
// ==========================================
// Hook Integration Tests
// ==========================================
describe('Hook Integration', () => {
describe('useToolIcon', () => {
it('should call useToolIcon with nodeData', () => {
// Arrange
const nodeData = createMockNodeData()
const props = createDefaultProps({ nodeData })
// Act
render(<Connect {...props} />)
// Assert
expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData)
})
it('should use toolIcon result from useToolIcon', () => {
// Arrange
mockUseToolIcon.mockReturnValue('custom-icon-url')
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - The hook should be called and its return value used
@@ -335,11 +267,9 @@ describe('Connect', () => {
})
it('should handle empty string icon', () => {
// Arrange
mockUseToolIcon.mockReturnValue('')
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should still render without crashing
@@ -347,11 +277,9 @@ describe('Connect', () => {
})
it('should handle undefined icon', () => {
// Arrange
mockUseToolIcon.mockReturnValue(undefined)
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should still render without crashing
@@ -361,10 +289,8 @@ describe('Connect', () => {
describe('useTranslation', () => {
it('should use correct translation keys for not connected message', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Should use the correct translation key (both notConnected and notConnectedTip contain similar pattern)
@@ -373,49 +299,36 @@ describe('Connect', () => {
})
it('should use correct translation key for tip message', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument()
})
it('should use correct translation key for connect button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toHaveTextContent('datasetCreation.stepOne.connect')
})
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
describe('Empty/Null Values', () => {
it('should handle empty title in nodeData', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: '' }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle undefined optional fields in nodeData', () => {
// Arrange
const minimalNodeData = {
title: 'Test',
plugin_id: 'test',
@@ -428,35 +341,28 @@ describe('Connect', () => {
} as DataSourceNodeType
const props = createDefaultProps({ nodeData: minimalNodeData })
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty plugin_id', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ plugin_id: '' }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Special Characters', () => {
it('should handle special characters in title', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: 'Drive <script>alert("xss")</script>' }),
})
// Act
render(<Connect {...props} />)
// Assert - Should render safely without executing script
@@ -464,75 +370,57 @@ describe('Connect', () => {
})
it('should handle unicode characters in title', () => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title: '云盘存储 🌐' }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long title', () => {
// Arrange
const longTitle = 'A'.repeat(500)
const props = createDefaultProps({
nodeData: createMockNodeData({ title: longTitle }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Icon Variations', () => {
it('should handle string icon URL', () => {
// Arrange
mockUseToolIcon.mockReturnValue('https://cdn.example.com/icon.png')
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle object icon with url property', () => {
// Arrange
mockUseToolIcon.mockReturnValue({ url: 'https://cdn.example.com/icon.png' })
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle null icon', () => {
// Arrange
mockUseToolIcon.mockReturnValue(null)
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ title: 'Google Drive', plugin_id: 'google-drive' },
@@ -541,15 +429,12 @@ describe('Connect', () => {
{ title: 'Amazon S3', plugin_id: 's3' },
{ title: 'Box', plugin_id: 'box' },
])('should render correctly with title=$title and plugin_id=$plugin_id', ({ title, plugin_id }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ title, plugin_id }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
expect(mockUseToolIcon).toHaveBeenCalledWith(
expect.objectContaining({ title, plugin_id }),
@@ -561,15 +446,12 @@ describe('Connect', () => {
{ provider_type: 'cloud_storage' },
{ provider_type: 'file_system' },
])('should render correctly with provider_type=$provider_type', ({ provider_type }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ provider_type }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
@@ -579,28 +461,20 @@ describe('Connect', () => {
{ datasource_label: '' },
{ datasource_label: 'S3 Bucket' },
])('should render correctly with datasource_label=$datasource_label', ({ datasource_label }) => {
// Arrange
const props = createDefaultProps({
nodeData: createMockNodeData({ datasource_label }),
})
// Act
render(<Connect {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// ==========================================
// Accessibility Tests
// ==========================================
describe('Accessibility', () => {
it('should have an accessible button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Button should be accessible by role
@@ -608,10 +482,8 @@ describe('Connect', () => {
})
it('should have proper text content for screen readers', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Connect {...props} />)
// Assert - Text content should be present

View File

@@ -2,18 +2,13 @@ import type { OnlineDriveFile } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { OnlineDriveFileType } from '@/models/pipeline'
import FileList from './index'
import FileList from '../index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
// Mock ahooks useDebounceFn - third-party library requires mocking
// Mock ahooks useDebounceFn: required because tests verify the debounced
// callback is invoked with specific arguments (mockDebounceFnRun assertions).
const mockDebounceFnRun = vi.fn()
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: any[]) => void) => {
useDebounceFn: (fn: (...args: unknown[]) => void) => {
mockDebounceFnRun.mockImplementation(fn)
return { run: mockDebounceFnRun }
},
@@ -35,14 +30,11 @@ const mockStoreState = {
const mockGetState = vi.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
vi.mock('../../store', () => ({
vi.mock('../../../store', () => ({
useDataSourceStore: () => mockDataSourceStore,
useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState),
useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState),
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({
id: 'file-1',
name: 'test-file.txt',
@@ -70,9 +62,6 @@ const createDefaultProps = (overrides?: Partial<FileListProps>): FileListProps =
...overrides,
})
// ==========================================
// Helper Functions
// ==========================================
const resetMockStoreState = () => {
mockStoreState.setNextPageParameters = vi.fn()
mockStoreState.currentNextPageParametersRef = { current: {} }
@@ -85,9 +74,6 @@ const resetMockStoreState = () => {
mockStoreState.setBucket = vi.fn()
}
// ==========================================
// Test Suites
// ==========================================
describe('FileList', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -95,15 +81,10 @@ describe('FileList', () => {
mockDebounceFnRun.mockClear()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileList {...props} />)
// Assert - search input should be visible
@@ -111,13 +92,10 @@ describe('FileList', () => {
})
it('should render with correct container styles', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<FileList {...props} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('h-[400px]')
@@ -127,38 +105,30 @@ describe('FileList', () => {
})
it('should render Header component with search input', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toBeInTheDocument()
})
it('should render files when fileList has items', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }),
createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }),
]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('file1.txt')).toBeInTheDocument()
expect(screen.getByText('file2.txt')).toBeInTheDocument()
})
it('should show loading state when isLoading is true and fileList is empty', () => {
// Arrange
const props = createDefaultProps({ isLoading: true, fileList: [] })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Loading component should be rendered with spin-animation class
@@ -166,35 +136,25 @@ describe('FileList', () => {
})
it('should show empty folder state when not loading and fileList is empty', () => {
// Arrange
const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '' })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should show empty search result when not loading, fileList is empty, and keywords exist', () => {
// Arrange
const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'search-term' })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument()
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('fileList prop', () => {
it('should render all files from fileList', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: '1', name: 'a.txt' }),
createMockOnlineDriveFile({ id: '2', name: 'b.txt' }),
@@ -202,20 +162,16 @@ describe('FileList', () => {
]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('c.txt')).toBeInTheDocument()
})
it('should handle empty fileList', () => {
// Arrange
const props = createDefaultProps({ fileList: [] })
// Act
render(<FileList {...props} />)
// Assert - Should show empty folder state
@@ -225,14 +181,12 @@ describe('FileList', () => {
describe('selectedFileIds prop', () => {
it('should mark files as selected based on selectedFileIds', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }),
createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }),
]
const props = createDefaultProps({ fileList, selectedFileIds: ['file-1'] })
// Act
render(<FileList {...props} />)
// Assert - The checkbox for file-1 should be checked (check icon present)
@@ -245,13 +199,10 @@ describe('FileList', () => {
describe('keywords prop', () => {
it('should initialize input with keywords value', () => {
// Arrange
const props = createDefaultProps({ keywords: 'my-search' })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('my-search')
})
@@ -259,10 +210,8 @@ describe('FileList', () => {
describe('isLoading prop', () => {
it('should show loading when isLoading is true with empty list', () => {
// Arrange
const props = createDefaultProps({ isLoading: true, fileList: [] })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Loading component with spin-animation class
@@ -270,11 +219,9 @@ describe('FileList', () => {
})
it('should show loading indicator at bottom when isLoading is true with files', () => {
// Arrange
const fileList = [createMockOnlineDriveFile()]
const props = createDefaultProps({ isLoading: true, fileList })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Should show spinner icon at the bottom
@@ -284,11 +231,9 @@ describe('FileList', () => {
describe('supportBatchUpload prop', () => {
it('should render checkboxes when supportBatchUpload is true', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })]
const props = createDefaultProps({ fileList, supportBatchUpload: true })
// Act
render(<FileList {...props} />)
// Assert - Checkbox component has data-testid="checkbox-{id}"
@@ -296,11 +241,9 @@ describe('FileList', () => {
})
it('should render radio buttons when supportBatchUpload is false', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })]
const props = createDefaultProps({ fileList, supportBatchUpload: false })
// Act
const { container } = render(<FileList {...props} />)
// Assert - Radio is rendered as a div with rounded-full class
@@ -311,99 +254,76 @@ describe('FileList', () => {
})
})
// ==========================================
// State Management Tests
// ==========================================
describe('State Management', () => {
describe('inputValue state', () => {
it('should initialize inputValue with keywords prop', () => {
// Arrange
const props = createDefaultProps({ keywords: 'initial-keyword' })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('initial-keyword')
})
it('should update inputValue when input changes', () => {
// Arrange
const props = createDefaultProps({ keywords: '' })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'new-value' } })
// Assert
expect(input).toHaveValue('new-value')
})
})
describe('debounced keywords update', () => {
it('should call updateKeywords with debounce when input changes', () => {
// Arrange
const mockUpdateKeywords = vi.fn()
const props = createDefaultProps({ updateKeywords: mockUpdateKeywords })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'debounced-value' } })
// Assert
expect(mockDebounceFnRun).toHaveBeenCalledWith('debounced-value')
})
})
})
// ==========================================
// Event Handlers Tests
// ==========================================
describe('Event Handlers', () => {
describe('handleInputChange', () => {
it('should update inputValue on input change', () => {
// Arrange
const props = createDefaultProps()
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'typed-text' } })
// Assert
expect(input).toHaveValue('typed-text')
})
it('should trigger debounced updateKeywords on input change', () => {
// Arrange
const mockUpdateKeywords = vi.fn()
const props = createDefaultProps({ updateKeywords: mockUpdateKeywords })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'search-term' } })
// Assert
expect(mockDebounceFnRun).toHaveBeenCalledWith('search-term')
})
it('should handle multiple sequential input changes', () => {
// Arrange
const mockUpdateKeywords = vi.fn()
const props = createDefaultProps({ updateKeywords: mockUpdateKeywords })
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
// Assert
expect(mockDebounceFnRun).toHaveBeenCalledTimes(3)
expect(mockDebounceFnRun).toHaveBeenLastCalledWith('abc')
expect(input).toHaveValue('abc')
@@ -412,7 +332,6 @@ describe('FileList', () => {
describe('handleResetKeywords', () => {
it('should call resetKeywords prop when clear button is clicked', () => {
// Arrange
const mockResetKeywords = vi.fn()
const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' })
const { container } = render(<FileList {...props} />)
@@ -422,12 +341,10 @@ describe('FileList', () => {
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
// Assert
expect(mockResetKeywords).toHaveBeenCalledTimes(1)
})
it('should reset inputValue to empty string when clear is clicked', () => {
// Arrange
const props = createDefaultProps({ keywords: 'to-be-reset' })
const { container } = render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
@@ -438,14 +355,12 @@ describe('FileList', () => {
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
// Assert
expect(input).toHaveValue('')
})
})
describe('handleSelectFile', () => {
it('should call handleSelectFile when file item is clicked', () => {
// Arrange
const mockHandleSelectFile = vi.fn()
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })]
const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList })
@@ -455,7 +370,6 @@ describe('FileList', () => {
const fileItem = screen.getByText('test.txt')
fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleSelectFile).toHaveBeenCalledWith(expect.objectContaining({
id: 'file-1',
name: 'test.txt',
@@ -466,7 +380,6 @@ describe('FileList', () => {
describe('handleOpenFolder', () => {
it('should call handleOpenFolder when folder item is clicked', () => {
// Arrange
const mockHandleOpenFolder = vi.fn()
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList })
@@ -476,7 +389,6 @@ describe('FileList', () => {
const folderItem = screen.getByText('my-folder')
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleOpenFolder).toHaveBeenCalledWith(expect.objectContaining({
id: 'folder-1',
name: 'my-folder',
@@ -486,68 +398,51 @@ describe('FileList', () => {
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle empty string keywords', () => {
// Arrange
const props = createDefaultProps({ keywords: '' })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('')
})
it('should handle special characters in keywords', () => {
// Arrange
const specialChars = 'test[file].txt (copy)'
const props = createDefaultProps({ keywords: specialChars })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(specialChars)
})
it('should handle unicode characters in keywords', () => {
// Arrange
const unicodeKeywords = '文件搜索 日本語'
const props = createDefaultProps({ keywords: unicodeKeywords })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(unicodeKeywords)
})
it('should handle very long file names in fileList', () => {
// Arrange
const longName = `${'a'.repeat(100)}.txt`
const fileList = [createMockOnlineDriveFile({ id: '1', name: longName })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle large number of files', () => {
// Arrange
const fileList = Array.from({ length: 50 }, (_, i) =>
createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }))
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert - Check a few files exist
@@ -556,23 +451,17 @@ describe('FileList', () => {
})
it('should handle whitespace-only keywords input', () => {
// Arrange
const props = createDefaultProps()
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: ' ' } })
// Assert
expect(input).toHaveValue(' ')
expect(mockDebounceFnRun).toHaveBeenCalledWith(' ')
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ isInPipeline: true, supportBatchUpload: true },
@@ -580,10 +469,8 @@ describe('FileList', () => {
{ isInPipeline: false, supportBatchUpload: true },
{ isInPipeline: false, supportBatchUpload: false },
])('should render correctly with isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', (propVariation) => {
// Arrange
const props = createDefaultProps(propVariation)
// Act
render(<FileList {...props} />)
// Assert - Component should render without crashing
@@ -595,15 +482,12 @@ describe('FileList', () => {
{ isLoading: false, fileCount: 0, description: 'not loading with no files' },
{ isLoading: false, fileCount: 3, description: 'not loading with files' },
])('should handle $description correctly', ({ isLoading, fileCount }) => {
// Arrange
const fileList = Array.from({ length: fileCount }, (_, i) =>
createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }))
const props = createDefaultProps({ isLoading, fileList })
// Act
const { container } = render(<FileList {...props} />)
// Assert
if (isLoading && fileCount === 0)
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
@@ -619,66 +503,50 @@ describe('FileList', () => {
{ keywords: 'test', searchResultsLength: 5 },
{ keywords: 'not-found', searchResultsLength: 0 },
])('should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', ({ keywords, searchResultsLength }) => {
// Arrange
const props = createDefaultProps({ keywords, searchResultsLength })
// Act
render(<FileList {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(keywords)
})
})
// ==========================================
// File Type Variations
// ==========================================
describe('File Type Variations', () => {
it('should render folder type correctly', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('my-folder')).toBeInTheDocument()
})
it('should render bucket type correctly', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('my-bucket')).toBeInTheDocument()
})
it('should render file with size', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt', size: 1024 })]
const props = createDefaultProps({ fileList })
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText('test.txt')).toBeInTheDocument()
// formatFileSize returns '1.00 KB' for 1024 bytes
expect(screen.getByText('1.00 KB')).toBeInTheDocument()
})
it('should not show checkbox for bucket type', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })]
const props = createDefaultProps({ fileList, supportBatchUpload: true })
// Act
render(<FileList {...props} />)
// Assert - No checkbox should be rendered for bucket
@@ -686,32 +554,24 @@ describe('FileList', () => {
})
})
// ==========================================
// Search Results Display
// ==========================================
describe('Search Results Display', () => {
it('should show search results count when keywords and results exist', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 5,
breadcrumbs: ['folder1'],
})
// Act
render(<FileList {...props} />)
// Assert
expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument()
})
})
// ==========================================
// Callback Stability
// ==========================================
describe('Callback Stability', () => {
it('should maintain stable handleSelectFile callback', () => {
// Arrange
const mockHandleSelectFile = vi.fn()
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })]
const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList })
@@ -724,15 +584,12 @@ describe('FileList', () => {
// Rerender with same props
rerender(<FileList {...props} />)
// Click again
fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleSelectFile).toHaveBeenCalledTimes(2)
})
it('should maintain stable handleOpenFolder callback', () => {
// Arrange
const mockHandleOpenFolder = vi.fn()
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList })
@@ -745,10 +602,8 @@ describe('FileList', () => {
// Rerender with same props
rerender(<FileList {...props} />)
// Click again
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
// Assert
expect(mockHandleOpenFolder).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from './index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
import Header from '../index'
// Mock store - required by Breadcrumbs component
const mockStoreState = {
@@ -23,14 +17,11 @@ const mockStoreState = {
const mockGetState = vi.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
vi.mock('../../../store', () => ({
vi.mock('../../../../store', () => ({
useDataSourceStore: () => mockDataSourceStore,
useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState),
}))
// ==========================================
// Test Data Builders
// ==========================================
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
@@ -45,9 +36,6 @@ const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
...overrides,
})
// ==========================================
// Helper Functions
// ==========================================
const resetMockStoreState = () => {
mockStoreState.hasBucket = false
mockStoreState.setOnlineDriveFileList = vi.fn()
@@ -59,24 +47,16 @@ const resetMockStoreState = () => {
mockStoreState.prefix = []
}
// ==========================================
// Test Suites
// ==========================================
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockStoreState()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - search input should be visible
@@ -84,10 +64,8 @@ describe('Header', () => {
})
it('should render with correct container styles', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - container should have correct class names
@@ -101,23 +79,18 @@ describe('Header', () => {
})
it('should render Input component with correct props', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'test-value' })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('test-value')
})
it('should render Input with search icon', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Input should have search icon (RiSearchLine is rendered as svg)
@@ -126,10 +99,8 @@ describe('Header', () => {
})
it('should render Input with correct wrapper width', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Input wrapper should have w-[200px] class
@@ -138,57 +109,42 @@ describe('Header', () => {
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('inputValue prop', () => {
it('should display empty input when inputValue is empty string', () => {
// Arrange
const props = createDefaultProps({ inputValue: '' })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('')
})
it('should display input value correctly', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'search-query' })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('search-query')
})
it('should handle special characters in inputValue', () => {
// Arrange
const specialChars = 'test[file].txt (copy)'
const props = createDefaultProps({ inputValue: specialChars })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(specialChars)
})
it('should handle unicode characters in inputValue', () => {
// Arrange
const unicodeValue = '文件搜索 日本語'
const props = createDefaultProps({ inputValue: unicodeValue })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(unicodeValue)
})
@@ -196,10 +152,8 @@ describe('Header', () => {
describe('breadcrumbs prop', () => {
it('should render with empty breadcrumbs', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: [] })
// Act
render(<Header {...props} />)
// Assert - Component should render without errors
@@ -207,34 +161,26 @@ describe('Header', () => {
})
it('should render with single breadcrumb', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder1'] })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with multiple breadcrumbs', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'] })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('keywords prop', () => {
it('should pass keywords to Breadcrumbs', () => {
// Arrange
const props = createDefaultProps({ keywords: 'search-keyword' })
// Act
render(<Header {...props} />)
// Assert - keywords are passed through, component renders
@@ -244,45 +190,34 @@ describe('Header', () => {
describe('bucket prop', () => {
it('should render with empty bucket', () => {
// Arrange
const props = createDefaultProps({ bucket: '' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render with bucket value', () => {
// Arrange
const props = createDefaultProps({ bucket: 'my-bucket' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('searchResultsLength prop', () => {
it('should handle zero search results', () => {
// Arrange
const props = createDefaultProps({ searchResultsLength: 0 })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle positive search results', () => {
// Arrange
const props = createDefaultProps({ searchResultsLength: 10, keywords: 'test' })
// Act
render(<Header {...props} />)
// Assert - Breadcrumbs will show search results text when keywords exist and results > 0
@@ -290,105 +225,82 @@ describe('Header', () => {
})
it('should handle large search results count', () => {
// Arrange
const props = createDefaultProps({ searchResultsLength: 1000, keywords: 'test' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
describe('isInPipeline prop', () => {
it('should render correctly when isInPipeline is false', () => {
// Arrange
const props = createDefaultProps({ isInPipeline: false })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should render correctly when isInPipeline is true', () => {
// Arrange
const props = createDefaultProps({ isInPipeline: true })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
})
})
// ==========================================
// Event Handlers Tests
// ==========================================
describe('Event Handlers', () => {
describe('handleInputChange', () => {
it('should call handleInputChange when input value changes', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'new-value' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
// Verify that onChange event was triggered (React's synthetic event structure)
expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change')
})
it('should call handleInputChange on each keystroke', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(3)
})
it('should handle empty string input', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const props = createDefaultProps({ inputValue: 'existing', handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: '' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change')
})
it('should handle whitespace-only input', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: ' ' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change')
})
@@ -396,7 +308,6 @@ describe('Header', () => {
describe('handleResetKeywords', () => {
it('should call handleResetKeywords when clear icon is clicked', () => {
// Arrange
const mockHandleResetKeywords = vi.fn()
const props = createDefaultProps({
inputValue: 'to-clear',
@@ -409,12 +320,10 @@ describe('Header', () => {
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
// Assert
expect(mockHandleResetKeywords).toHaveBeenCalledTimes(1)
})
it('should not show clear icon when inputValue is empty', () => {
// Arrange
const props = createDefaultProps({ inputValue: '' })
const { container } = render(<Header {...props} />)
@@ -424,7 +333,6 @@ describe('Header', () => {
})
it('should show clear icon when inputValue is not empty', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'some-value' })
const { container } = render(<Header {...props} />)
@@ -435,9 +343,7 @@ describe('Header', () => {
})
})
// ==========================================
// Component Memoization Tests
// ==========================================
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - Header component should be memoized
@@ -445,7 +351,6 @@ describe('Header', () => {
})
it('should not re-render when props are the same', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const mockHandleResetKeywords = vi.fn()
const props = createDefaultProps({
@@ -464,7 +369,6 @@ describe('Header', () => {
})
it('should re-render when inputValue changes', () => {
// Arrange
const props = createDefaultProps({ inputValue: 'initial' })
const { rerender } = render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
@@ -479,7 +383,6 @@ describe('Header', () => {
})
it('should re-render when breadcrumbs change', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: [] })
const { rerender } = render(<Header {...props} />)
@@ -492,7 +395,6 @@ describe('Header', () => {
})
it('should re-render when keywords change', () => {
// Arrange
const props = createDefaultProps({ keywords: '' })
const { rerender } = render(<Header {...props} />)
@@ -505,78 +407,58 @@ describe('Header', () => {
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle very long inputValue', () => {
// Arrange
const longValue = 'a'.repeat(500)
const props = createDefaultProps({ inputValue: longValue })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(longValue)
})
it('should handle very long breadcrumb paths', () => {
// Arrange
const longBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`)
const props = createDefaultProps({ breadcrumbs: longBreadcrumbs })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle breadcrumbs with special characters', () => {
// Arrange
const specialBreadcrumbs = ['folder [1]', 'folder (2)', 'folder-3.backup']
const props = createDefaultProps({ breadcrumbs: specialBreadcrumbs })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle breadcrumbs with unicode names', () => {
// Arrange
const unicodeBreadcrumbs = ['文件夹', 'フォルダ', 'Папка']
const props = createDefaultProps({ breadcrumbs: unicodeBreadcrumbs })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should handle bucket with special characters', () => {
// Arrange
const props = createDefaultProps({ bucket: 'my-bucket_2024.backup' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
it('should pass the event object to handleInputChange callback', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
render(<Header {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
// Act
fireEvent.change(input, { target: { value: 'test-value' } })
// Assert - Verify the event object is passed correctly
@@ -587,9 +469,6 @@ describe('Header', () => {
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ isInPipeline: true, bucket: '' },
@@ -597,13 +476,10 @@ describe('Header', () => {
{ isInPipeline: false, bucket: '' },
{ isInPipeline: false, bucket: 'my-bucket' },
])('should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', (propVariation) => {
// Arrange
const props = createDefaultProps(propVariation)
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
@@ -613,13 +489,10 @@ describe('Header', () => {
{ keywords: 'test', searchResultsLength: 5, description: 'search with results' },
{ keywords: '', searchResultsLength: 5, description: 'no keywords but has results count' },
])('should render correctly with $description', ({ keywords, searchResultsLength }) => {
// Arrange
const props = createDefaultProps({ keywords, searchResultsLength })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
})
@@ -629,24 +502,18 @@ describe('Header', () => {
{ breadcrumbs: ['a', 'b', 'c'], inputValue: '', expected: 'multiple breadcrumbs no search' },
{ breadcrumbs: ['a', 'b', 'c', 'd', 'e'], inputValue: 'query', expected: 'many breadcrumbs with search' },
])('should handle $expected correctly', ({ breadcrumbs, inputValue }) => {
// Arrange
const props = createDefaultProps({ breadcrumbs, inputValue })
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue(inputValue)
})
})
// ==========================================
// Integration with Child Components
// ==========================================
describe('Integration with Child Components', () => {
it('should pass all required props to Breadcrumbs', () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
keywords: 'test-keyword',
@@ -655,7 +522,6 @@ describe('Header', () => {
isInPipeline: true,
})
// Act
render(<Header {...props} />)
// Assert - Component should render successfully, meaning props are passed correctly
@@ -663,7 +529,6 @@ describe('Header', () => {
})
it('should pass correct props to Input component', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const mockHandleResetKeywords = vi.fn()
const props = createDefaultProps({
@@ -672,10 +537,8 @@ describe('Header', () => {
handleResetKeywords: mockHandleResetKeywords,
})
// Act
render(<Header {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
expect(input).toHaveValue('test-input')
@@ -685,12 +548,9 @@ describe('Header', () => {
})
})
// ==========================================
// Callback Stability Tests
// ==========================================
describe('Callback Stability', () => {
it('should maintain stable handleInputChange callback after rerender', () => {
// Arrange
const mockHandleInputChange = vi.fn()
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
const { rerender } = render(<Header {...props} />)
@@ -701,12 +561,10 @@ describe('Header', () => {
rerender(<Header {...props} />)
fireEvent.change(input, { target: { value: 'second' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledTimes(2)
})
it('should maintain stable handleResetKeywords callback after rerender', () => {
// Arrange
const mockHandleResetKeywords = vi.fn()
const props = createDefaultProps({
inputValue: 'to-clear',
@@ -720,7 +578,6 @@ describe('Header', () => {
rerender(<Header {...props} />)
fireEvent.click(clearButton!)
// Assert
expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Bucket from '../bucket'
vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({
BucketsGray: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="buckets-gray" {...props} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}))
describe('Bucket', () => {
const defaultProps = {
bucketName: 'my-bucket',
handleBackToBucketList: vi.fn(),
handleClickBucketName: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render bucket name', () => {
render(<Bucket {...defaultProps} />)
expect(screen.getByText('my-bucket')).toBeInTheDocument()
})
it('should render bucket icon', () => {
render(<Bucket {...defaultProps} />)
expect(screen.getByTestId('buckets-gray')).toBeInTheDocument()
})
it('should call handleBackToBucketList on icon button click', () => {
render(<Bucket {...defaultProps} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce()
})
it('should call handleClickBucketName on name click', () => {
render(<Bucket {...defaultProps} />)
fireEvent.click(screen.getByText('my-bucket'))
expect(defaultProps.handleClickBucketName).toHaveBeenCalledOnce()
})
it('should not call handleClickBucketName when disabled', () => {
render(<Bucket {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('my-bucket'))
expect(defaultProps.handleClickBucketName).not.toHaveBeenCalled()
})
it('should show separator by default', () => {
render(<Bucket {...defaultProps} />)
const separators = screen.getAllByText('/')
expect(separators.length).toBeGreaterThanOrEqual(2) // One after icon, one after name
})
})

View File

@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Drive from '../drive'
describe('Drive', () => {
const defaultProps = {
breadcrumbs: [] as string[],
handleBackToRoot: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: button text and separator visibility
describe('Rendering', () => {
it('should render "All Files" button text', () => {
render(<Drive {...defaultProps} />)
expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles')
})
it('should show separator "/" when breadcrumbs has items', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should hide separator when breadcrumbs is empty', () => {
render(<Drive {...defaultProps} breadcrumbs={[]} />)
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
})
// Props: disabled state depends on breadcrumbs length
describe('Props', () => {
it('should disable button when breadcrumbs is empty', () => {
render(<Drive {...defaultProps} breadcrumbs={[]} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable button when breadcrumbs has items', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A', 'Folder B']} />)
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
// User interactions: clicking the root button
describe('User Interactions', () => {
it('should call handleBackToRoot on click when enabled', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.handleBackToRoot).toHaveBeenCalledOnce()
})
})
})

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Breadcrumbs from './index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
import Breadcrumbs from '../index'
// Mock store - context provider requires mocking
const mockStoreState = {
@@ -23,14 +17,11 @@ const mockStoreState = {
const mockGetState = vi.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
vi.mock('../../../../store', () => ({
vi.mock('../../../../../store', () => ({
useDataSourceStore: () => mockDataSourceStore,
useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState),
}))
// ==========================================
// Test Data Builders
// ==========================================
type BreadcrumbsProps = React.ComponentProps<typeof Breadcrumbs>
const createDefaultProps = (overrides?: Partial<BreadcrumbsProps>): BreadcrumbsProps => ({
@@ -42,9 +33,6 @@ const createDefaultProps = (overrides?: Partial<BreadcrumbsProps>): BreadcrumbsP
...overrides,
})
// ==========================================
// Helper Functions
// ==========================================
const resetMockStoreState = () => {
mockStoreState.hasBucket = false
mockStoreState.breadcrumbs = []
@@ -56,24 +44,16 @@ const resetMockStoreState = () => {
mockStoreState.setBucket = vi.fn()
}
// ==========================================
// Test Suites
// ==========================================
describe('Breadcrumbs', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockStoreState()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Breadcrumbs {...props} />)
// Assert - Container should be in the document
@@ -82,13 +62,10 @@ describe('Breadcrumbs', () => {
})
it('should render with correct container styles', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Breadcrumbs {...props} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('grow')
@@ -98,14 +75,12 @@ describe('Breadcrumbs', () => {
describe('Search Results Display', () => {
it('should show search results when keywords and searchResultsLength > 0', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 5,
breadcrumbs: ['folder1'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Search result text should be displayed
@@ -113,36 +88,29 @@ describe('Breadcrumbs', () => {
})
it('should not show search results when keywords is empty', () => {
// Arrange
const props = createDefaultProps({
keywords: '',
searchResultsLength: 5,
breadcrumbs: ['folder1'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument()
})
it('should not show search results when searchResultsLength is 0', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 0,
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument()
})
it('should use bucket as folderName when breadcrumbs is empty', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 5,
@@ -150,7 +118,6 @@ describe('Breadcrumbs', () => {
bucket: 'my-bucket',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should use bucket name in search result
@@ -158,7 +125,6 @@ describe('Breadcrumbs', () => {
})
it('should use last breadcrumb as folderName when breadcrumbs exist', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 5,
@@ -166,7 +132,6 @@ describe('Breadcrumbs', () => {
bucket: 'my-bucket',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should use last breadcrumb in search result
@@ -176,7 +141,6 @@ describe('Breadcrumbs', () => {
describe('All Buckets Title Display', () => {
it('should show all buckets title when hasBucket=true, bucket is empty, and no breadcrumbs', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
breadcrumbs: [],
@@ -184,37 +148,30 @@ describe('Breadcrumbs', () => {
keywords: '',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).toBeInTheDocument()
})
it('should not show all buckets title when breadcrumbs exist', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
breadcrumbs: ['folder1'],
bucket: '',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument()
})
it('should not show all buckets title when bucket is set', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
breadcrumbs: [],
bucket: 'my-bucket',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should show bucket name instead
@@ -224,14 +181,12 @@ describe('Breadcrumbs', () => {
describe('Bucket Component Display', () => {
it('should render Bucket component when hasBucket and bucket are set', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'test-bucket',
breadcrumbs: [],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Bucket name should be displayed
@@ -239,14 +194,12 @@ describe('Breadcrumbs', () => {
})
it('should not render Bucket when hasBucket is false', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
bucket: 'test-bucket',
breadcrumbs: [],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Bucket should not be displayed, Drive should be shown instead
@@ -256,13 +209,11 @@ describe('Breadcrumbs', () => {
describe('Drive Component Display', () => {
it('should render Drive component when hasBucket is false', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: [],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - "All Files" should be displayed
@@ -270,46 +221,38 @@ describe('Breadcrumbs', () => {
})
it('should not render Drive component when hasBucket is true', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'test-bucket',
breadcrumbs: [],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).not.toBeInTheDocument()
})
})
describe('BreadcrumbItem Display', () => {
it('should render all breadcrumbs when not collapsed', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
isInPipeline: false,
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('folder1')).toBeInTheDocument()
expect(screen.getByText('folder2')).toBeInTheDocument()
})
it('should render last breadcrumb as active', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Last breadcrumb should have active styles
@@ -319,13 +262,11 @@ describe('Breadcrumbs', () => {
})
it('should render non-last breadcrumbs with tertiary styles', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - First breadcrumb should have tertiary styles
@@ -337,14 +278,12 @@ describe('Breadcrumbs', () => {
describe('Collapsed Breadcrumbs (Dropdown)', () => {
it('should show dropdown when breadcrumbs exceed displayBreadcrumbNum', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4'],
isInPipeline: false, // displayBreadcrumbNum = 3
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Dropdown trigger (more button) should be present
@@ -352,14 +291,12 @@ describe('Breadcrumbs', () => {
})
it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
isInPipeline: false, // displayBreadcrumbNum = 3
})
// Act
const { container } = render(<Breadcrumbs {...props} />)
// Assert - Should not have dropdown, just regular breadcrumbs
@@ -372,14 +309,12 @@ describe('Breadcrumbs', () => {
})
it('should show prefix breadcrumbs and last breadcrumb when collapsed', async () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'],
isInPipeline: false, // displayBreadcrumbNum = 3
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - First breadcrumb and last breadcrumb should be visible
@@ -392,7 +327,6 @@ describe('Breadcrumbs', () => {
})
it('should show collapsed breadcrumbs in dropdown when clicked', async () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'],
@@ -414,17 +348,12 @@ describe('Breadcrumbs', () => {
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('breadcrumbs prop', () => {
it('should handle empty breadcrumbs array', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({ breadcrumbs: [] })
// Act
render(<Breadcrumbs {...props} />)
// Assert - Only Drive should be visible
@@ -432,43 +361,34 @@ describe('Breadcrumbs', () => {
})
it('should handle single breadcrumb', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({ breadcrumbs: ['single-folder'] })
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('single-folder')).toBeInTheDocument()
})
it('should handle breadcrumbs with special characters', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder [1]', 'folder (copy)'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('folder [1]')).toBeInTheDocument()
expect(screen.getByText('folder (copy)')).toBeInTheDocument()
})
it('should handle breadcrumbs with unicode characters', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['文件夹', 'フォルダ'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('文件夹')).toBeInTheDocument()
expect(screen.getByText('フォルダ')).toBeInTheDocument()
})
@@ -476,27 +396,22 @@ describe('Breadcrumbs', () => {
describe('keywords prop', () => {
it('should show search results when keywords is non-empty with results', () => {
// Arrange
const props = createDefaultProps({
keywords: 'search-term',
searchResultsLength: 10,
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText(/searchResult/)).toBeInTheDocument()
})
it('should handle whitespace keywords', () => {
// Arrange
const props = createDefaultProps({
keywords: ' ',
searchResultsLength: 5,
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Whitespace is truthy, so should show search results
@@ -506,43 +421,35 @@ describe('Breadcrumbs', () => {
describe('bucket prop', () => {
it('should display bucket name when hasBucket and bucket are set', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'production-bucket',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('production-bucket')).toBeInTheDocument()
})
it('should handle bucket with special characters', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'bucket-v2.0_backup',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText('bucket-v2.0_backup')).toBeInTheDocument()
})
})
describe('searchResultsLength prop', () => {
it('should handle zero searchResultsLength', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 0,
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should not show search results
@@ -550,30 +457,25 @@ describe('Breadcrumbs', () => {
})
it('should handle large searchResultsLength', () => {
// Arrange
const props = createDefaultProps({
keywords: 'test',
searchResultsLength: 10000,
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText(/searchResult.*10000/)).toBeInTheDocument()
})
})
describe('isInPipeline prop', () => {
it('should use displayBreadcrumbNum=2 when isInPipeline is true', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2', 'folder3'],
isInPipeline: true, // displayBreadcrumbNum = 2
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should collapse because 3 > 2
@@ -584,14 +486,12 @@ describe('Breadcrumbs', () => {
})
it('should use displayBreadcrumbNum=3 when isInPipeline is false', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2', 'folder3'],
isInPipeline: false, // displayBreadcrumbNum = 3
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should NOT collapse because 3 <= 3
@@ -601,7 +501,6 @@ describe('Breadcrumbs', () => {
})
it('should reduce displayBreadcrumbNum by 1 when bucket is set', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2', 'folder3'],
@@ -609,7 +508,6 @@ describe('Breadcrumbs', () => {
isInPipeline: false, // displayBreadcrumbNum = 3 - 1 = 2
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should collapse because 3 > 2
@@ -620,13 +518,10 @@ describe('Breadcrumbs', () => {
})
})
// ==========================================
// Memoization Logic and Dependencies Tests
// ==========================================
describe('Memoization Logic and Dependencies', () => {
describe('displayBreadcrumbNum useMemo', () => {
it('should calculate correct value when isInPipeline=false and no bucket', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['a', 'b', 'c', 'd'],
@@ -634,7 +529,6 @@ describe('Breadcrumbs', () => {
bucket: '',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - displayBreadcrumbNum = 3, so 4 breadcrumbs should collapse
@@ -646,7 +540,6 @@ describe('Breadcrumbs', () => {
})
it('should calculate correct value when isInPipeline=true and no bucket', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['a', 'b', 'c'],
@@ -654,7 +547,6 @@ describe('Breadcrumbs', () => {
bucket: '',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - displayBreadcrumbNum = 2, so 3 breadcrumbs should collapse
@@ -664,7 +556,6 @@ describe('Breadcrumbs', () => {
})
it('should calculate correct value when isInPipeline=false and bucket exists', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
breadcrumbs: ['a', 'b', 'c'],
@@ -672,7 +563,6 @@ describe('Breadcrumbs', () => {
bucket: 'my-bucket',
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - displayBreadcrumbNum = 3 - 1 = 2, so 3 breadcrumbs should collapse
@@ -684,7 +574,6 @@ describe('Breadcrumbs', () => {
describe('breadcrumbsConfig useMemo', () => {
it('should correctly split breadcrumbs when collapsed', async () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['f1', 'f2', 'f3', 'f4', 'f5'],
@@ -697,7 +586,6 @@ describe('Breadcrumbs', () => {
if (dropdownTrigger)
fireEvent.click(dropdownTrigger)
// Assert
// prefixBreadcrumbs = ['f1', 'f2']
// collapsedBreadcrumbs = ['f3', 'f4']
// lastBreadcrumb = 'f5'
@@ -711,14 +599,12 @@ describe('Breadcrumbs', () => {
})
it('should not collapse when breadcrumbs.length <= displayBreadcrumbNum', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['f1', 'f2'],
isInPipeline: false, // displayBreadcrumbNum = 3
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - All breadcrumbs should be visible
@@ -728,13 +614,10 @@ describe('Breadcrumbs', () => {
})
})
// ==========================================
// Callback Stability and Event Handlers Tests
// ==========================================
describe('Callback Stability and Event Handlers', () => {
describe('handleBackToBucketList', () => {
it('should reset store state when called', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'my-bucket',
@@ -746,7 +629,6 @@ describe('Breadcrumbs', () => {
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0]) // Bucket icon button
// Assert
expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([])
expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([])
expect(mockStoreState.setBucket).toHaveBeenCalledWith('')
@@ -757,7 +639,6 @@ describe('Breadcrumbs', () => {
describe('handleClickBucketName', () => {
it('should reset breadcrumbs and prefix when bucket name is clicked', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'my-bucket',
@@ -769,7 +650,6 @@ describe('Breadcrumbs', () => {
const bucketButton = screen.getByText('my-bucket')
fireEvent.click(bucketButton)
// Assert
expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([])
expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([])
expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([])
@@ -777,7 +657,6 @@ describe('Breadcrumbs', () => {
})
it('should not call handler when bucket is disabled (no breadcrumbs)', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: 'my-bucket',
@@ -796,7 +675,6 @@ describe('Breadcrumbs', () => {
describe('handleBackToRoot', () => {
it('should reset state when Drive button is clicked', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1'],
@@ -807,7 +685,6 @@ describe('Breadcrumbs', () => {
const driveButton = screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')
fireEvent.click(driveButton)
// Assert
expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([])
expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([])
expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([])
@@ -817,7 +694,6 @@ describe('Breadcrumbs', () => {
describe('handleClickBreadcrumb', () => {
it('should slice breadcrumbs and prefix when breadcrumb is clicked', () => {
// Arrange
mockStoreState.hasBucket = false
mockStoreState.breadcrumbs = ['folder1', 'folder2', 'folder3']
mockStoreState.prefix = ['prefix1', 'prefix2', 'prefix3']
@@ -838,7 +714,6 @@ describe('Breadcrumbs', () => {
})
it('should not call handler when last breadcrumb is clicked (disabled)', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: ['folder1', 'folder2'],
@@ -854,7 +729,6 @@ describe('Breadcrumbs', () => {
})
it('should handle click on collapsed breadcrumb from dropdown', async () => {
// Arrange
mockStoreState.hasBucket = false
mockStoreState.breadcrumbs = ['f1', 'f2', 'f3', 'f4', 'f5']
mockStoreState.prefix = ['p1', 'p2', 'p3', 'p4', 'p5']
@@ -882,17 +756,13 @@ describe('Breadcrumbs', () => {
})
})
// ==========================================
// Component Memoization Tests
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(Breadcrumbs).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should not re-render when props are the same', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<Breadcrumbs {...props} />)
@@ -905,7 +775,6 @@ describe('Breadcrumbs', () => {
})
it('should re-render when breadcrumbs change', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({ breadcrumbs: ['folder1'] })
const { rerender } = render(<Breadcrumbs {...props} />)
@@ -914,32 +783,25 @@ describe('Breadcrumbs', () => {
// Act - Rerender with different breadcrumbs
rerender(<Breadcrumbs {...createDefaultProps({ breadcrumbs: ['folder2'] })} />)
// Assert
expect(screen.getByText('folder2')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases and Error Handling Tests
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle very long breadcrumb names', () => {
// Arrange
mockStoreState.hasBucket = false
const longName = 'a'.repeat(100)
const props = createDefaultProps({
breadcrumbs: [longName],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle many breadcrumbs', async () => {
// Arrange
mockStoreState.hasBucket = false
const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`)
const props = createDefaultProps({
@@ -962,14 +824,12 @@ describe('Breadcrumbs', () => {
})
it('should handle empty bucket string', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
bucket: '',
breadcrumbs: [],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should show all buckets title
@@ -977,13 +837,11 @@ describe('Breadcrumbs', () => {
})
it('should handle breadcrumb with only whitespace', () => {
// Arrange
mockStoreState.hasBucket = false
const props = createDefaultProps({
breadcrumbs: [' ', 'normal-folder'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Both should be rendered
@@ -991,9 +849,6 @@ describe('Breadcrumbs', () => {
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ hasBucket: true, bucket: 'b1', breadcrumbs: [], expected: 'bucket visible' },
@@ -1001,11 +856,9 @@ describe('Breadcrumbs', () => {
{ hasBucket: false, bucket: '', breadcrumbs: [], expected: 'all files' },
{ hasBucket: false, bucket: '', breadcrumbs: ['f1'], expected: 'drive with breadcrumb' },
])('should render correctly for $expected', ({ hasBucket, bucket, breadcrumbs }) => {
// Arrange
mockStoreState.hasBucket = hasBucket
const props = createDefaultProps({ bucket, breadcrumbs })
// Act
render(<Breadcrumbs {...props} />)
// Assert - Component should render without errors
@@ -1019,12 +872,10 @@ describe('Breadcrumbs', () => {
{ isInPipeline: true, bucket: 'b', expectedNum: 1 },
{ isInPipeline: false, bucket: 'b', expectedNum: 2 },
])('should calculate displayBreadcrumbNum=$expectedNum when isInPipeline=$isInPipeline and bucket=$bucket', ({ isInPipeline, bucket, expectedNum }) => {
// Arrange
mockStoreState.hasBucket = !!bucket
const breadcrumbs = Array.from({ length: expectedNum + 2 }, (_, i) => `f${i}`)
const props = createDefaultProps({ isInPipeline, bucket, breadcrumbs })
// Act
render(<Breadcrumbs {...props} />)
// Assert - Should collapse because breadcrumbs.length > expectedNum
@@ -1034,12 +885,8 @@ describe('Breadcrumbs', () => {
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should handle full navigation flow: bucket -> folders -> navigation back', () => {
// Arrange
mockStoreState.hasBucket = true
mockStoreState.breadcrumbs = ['folder1', 'folder2']
mockStoreState.prefix = ['prefix1', 'prefix2']
@@ -1053,13 +900,11 @@ describe('Breadcrumbs', () => {
const firstFolder = screen.getByText('folder1')
fireEvent.click(firstFolder)
// Assert
expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['folder1'])
expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['prefix1'])
})
it('should handle search result display with navigation elements hidden', () => {
// Arrange
mockStoreState.hasBucket = true
const props = createDefaultProps({
keywords: 'test',
@@ -1068,7 +913,6 @@ describe('Breadcrumbs', () => {
breadcrumbs: ['folder1'],
})
// Act
render(<Breadcrumbs {...props} />)
// Assert - Search result should be shown, navigation elements should be hidden

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BreadcrumbItem from '../item'
describe('BreadcrumbItem', () => {
const defaultProps = {
name: 'Documents',
index: 2,
handleClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render name', () => {
render(<BreadcrumbItem {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
it('should show separator by default', () => {
render(<BreadcrumbItem {...defaultProps} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should hide separator when showSeparator is false', () => {
render(<BreadcrumbItem {...defaultProps} showSeparator={false} />)
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
it('should call handleClick with index on click', () => {
render(<BreadcrumbItem {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.handleClick).toHaveBeenCalledWith(2)
})
it('should not call handleClick when disabled', () => {
render(<BreadcrumbItem {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.handleClick).not.toHaveBeenCalled()
})
it('should apply active styling', () => {
render(<BreadcrumbItem {...defaultProps} isActive={true} />)
const btn = screen.getByRole('button')
expect(btn.className).toContain('system-sm-medium')
})
})

View File

@@ -1,14 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Dropdown from './index'
import Dropdown from '../index'
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
// ==========================================
// ==========================================
// Test Data Builders
// ==========================================
type DropdownProps = React.ComponentProps<typeof Dropdown>
const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps => ({
@@ -18,23 +11,15 @@ const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps =
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('Dropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert - Trigger button should be visible
@@ -42,10 +27,8 @@ describe('Dropdown', () => {
})
it('should render trigger button with more icon', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Dropdown {...props} />)
// Assert - Button should have RiMoreFill icon (rendered as svg)
@@ -55,10 +38,8 @@ describe('Dropdown', () => {
})
it('should render separator after dropdown', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert - Separator "/" should be visible
@@ -66,13 +47,10 @@ describe('Dropdown', () => {
})
it('should render trigger button with correct default styles', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('flex')
expect(button).toHaveClass('size-6')
@@ -82,10 +60,8 @@ describe('Dropdown', () => {
})
it('should not render menu content when closed', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['visible-folder'] })
// Act
render(<Dropdown {...props} />)
// Assert - Menu content should not be visible when dropdown is closed
@@ -93,7 +69,6 @@ describe('Dropdown', () => {
})
it('should render menu content when opened', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder1', 'test-folder2'] })
render(<Dropdown {...props} />)
@@ -108,13 +83,9 @@ describe('Dropdown', () => {
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('startIndex prop', () => {
it('should pass startIndex to Menu component', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 5,
@@ -137,7 +108,6 @@ describe('Dropdown', () => {
})
it('should calculate correct index for second item', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 3,
@@ -162,16 +132,13 @@ describe('Dropdown', () => {
describe('breadcrumbs prop', () => {
it('should render all breadcrumbs in menu', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['folder-a', 'folder-b', 'folder-c'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('folder-a')).toBeInTheDocument()
expect(screen.getByText('folder-b')).toBeInTheDocument()
@@ -180,29 +147,24 @@ describe('Dropdown', () => {
})
it('should handle single breadcrumb', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['single-folder'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('single-folder')).toBeInTheDocument()
})
})
it('should handle empty breadcrumbs array', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: [],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - Menu should be rendered but with no items
@@ -213,16 +175,13 @@ describe('Dropdown', () => {
})
it('should handle breadcrumbs with special characters', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['folder [1]', 'folder (copy)', 'folder-v2.0'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('folder [1]')).toBeInTheDocument()
expect(screen.getByText('folder (copy)')).toBeInTheDocument()
@@ -231,16 +190,13 @@ describe('Dropdown', () => {
})
it('should handle breadcrumbs with unicode characters', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['文件夹', 'フォルダ', 'Папка'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('文件夹')).toBeInTheDocument()
expect(screen.getByText('フォルダ')).toBeInTheDocument()
@@ -251,7 +207,6 @@ describe('Dropdown', () => {
describe('onBreadcrumbClick prop', () => {
it('should call onBreadcrumbClick with correct index when item clicked', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 0,
@@ -260,7 +215,6 @@ describe('Dropdown', () => {
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
@@ -269,23 +223,17 @@ describe('Dropdown', () => {
fireEvent.click(screen.getByText('folder1'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0)
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// State Management Tests
// ==========================================
describe('State Management', () => {
describe('open state', () => {
it('should initialize with closed state', () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
// Act
render(<Dropdown {...props} />)
// Assert - Menu content should not be visible
@@ -293,21 +241,17 @@ describe('Dropdown', () => {
})
it('should toggle to open state when trigger is clicked', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('test-folder')).toBeInTheDocument()
})
})
it('should toggle to closed state when trigger is clicked again', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
render(<Dropdown {...props} />)
@@ -319,14 +263,12 @@ describe('Dropdown', () => {
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.queryByText('test-folder')).not.toBeInTheDocument()
})
})
it('should close when breadcrumb item is clicked', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
breadcrumbs: ['test-folder'],
@@ -341,7 +283,6 @@ describe('Dropdown', () => {
expect(screen.getByText('test-folder')).toBeInTheDocument()
})
// Click on breadcrumb item
fireEvent.click(screen.getByText('test-folder'))
// Assert - Menu should close
@@ -351,7 +292,6 @@ describe('Dropdown', () => {
})
it('should apply correct button styles based on open state', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['test-folder'] })
render(<Dropdown {...props} />)
const button = screen.getByRole('button')
@@ -370,13 +310,10 @@ describe('Dropdown', () => {
})
})
// ==========================================
// Event Handlers Tests
// ==========================================
describe('Event Handlers', () => {
describe('handleTrigger', () => {
it('should toggle open state when trigger is clicked', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder'] })
render(<Dropdown {...props} />)
@@ -393,7 +330,6 @@ describe('Dropdown', () => {
})
it('should toggle multiple times correctly', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder'] })
render(<Dropdown {...props} />)
const button = screen.getByRole('button')
@@ -421,7 +357,6 @@ describe('Dropdown', () => {
describe('handleBreadCrumbClick', () => {
it('should call onBreadcrumbClick and close menu', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
breadcrumbs: ['folder1'],
@@ -436,10 +371,8 @@ describe('Dropdown', () => {
expect(screen.getByText('folder1')).toBeInTheDocument()
})
// Click on breadcrumb
fireEvent.click(screen.getByText('folder1'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1)
// Menu should close
@@ -449,7 +382,6 @@ describe('Dropdown', () => {
})
it('should pass correct index to onBreadcrumbClick for each item', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 2,
@@ -473,9 +405,7 @@ describe('Dropdown', () => {
})
})
// ==========================================
// Callback Stability and Memoization Tests
// ==========================================
describe('Callback Stability and Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - Dropdown component should be memoized
@@ -483,7 +413,6 @@ describe('Dropdown', () => {
})
it('should maintain stable callback after rerender with same props', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
breadcrumbs: ['folder'],
@@ -506,12 +435,10 @@ describe('Dropdown', () => {
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(2)
})
it('should update callback when onBreadcrumbClick prop changes', async () => {
// Arrange
const mockOnBreadcrumbClick1 = vi.fn()
const mockOnBreadcrumbClick2 = vi.fn()
const props = createDefaultProps({
@@ -543,13 +470,11 @@ describe('Dropdown', () => {
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick1).toHaveBeenCalledTimes(1)
expect(mockOnBreadcrumbClick2).toHaveBeenCalledTimes(1)
})
it('should not re-render when props are the same', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<Dropdown {...props} />)
@@ -561,12 +486,8 @@ describe('Dropdown', () => {
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle rapid toggle clicks', async () => {
// Arrange
const props = createDefaultProps({ breadcrumbs: ['folder'] })
render(<Dropdown {...props} />)
const button = screen.getByRole('button')
@@ -583,31 +504,26 @@ describe('Dropdown', () => {
})
it('should handle very long folder names', async () => {
// Arrange
const longName = 'a'.repeat(100)
const props = createDefaultProps({
breadcrumbs: [longName],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText(longName)).toBeInTheDocument()
})
})
it('should handle many breadcrumbs', async () => {
// Arrange
const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`)
const props = createDefaultProps({
breadcrumbs: manyBreadcrumbs,
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - First and last items should be visible
@@ -618,7 +534,6 @@ describe('Dropdown', () => {
})
it('should handle startIndex of 0', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 0,
@@ -627,19 +542,16 @@ describe('Dropdown', () => {
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0)
})
it('should handle large startIndex values', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 999,
@@ -648,53 +560,42 @@ describe('Dropdown', () => {
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('folder'))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(999)
})
it('should handle breadcrumbs with whitespace-only names', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: [' ', 'normal-folder'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('normal-folder')).toBeInTheDocument()
})
})
it('should handle breadcrumbs with empty string', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['', 'folder'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('folder')).toBeInTheDocument()
})
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ startIndex: 0, breadcrumbs: ['a'], expectedIndex: 0 },
@@ -702,7 +603,6 @@ describe('Dropdown', () => {
{ startIndex: 5, breadcrumbs: ['a'], expectedIndex: 5 },
{ startIndex: 10, breadcrumbs: ['a', 'b'], expectedIndex: 10 },
])('should handle startIndex=$startIndex correctly', async ({ startIndex, breadcrumbs, expectedIndex }) => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex,
@@ -711,14 +611,12 @@ describe('Dropdown', () => {
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument()
})
fireEvent.click(screen.getByText(breadcrumbs[0]))
// Assert
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex)
})
@@ -728,10 +626,8 @@ describe('Dropdown', () => {
{ breadcrumbs: ['a', 'b'], description: 'two items' },
{ breadcrumbs: ['a', 'b', 'c', 'd', 'e'], description: 'five items' },
])('should render correctly with $description breadcrumbs', async ({ breadcrumbs }) => {
// Arrange
const props = createDefaultProps({ breadcrumbs })
// Act
render(<Dropdown {...props} />)
fireEvent.click(screen.getByRole('button'))
@@ -743,21 +639,16 @@ describe('Dropdown', () => {
})
})
// ==========================================
// Integration Tests (Menu and Item)
// ==========================================
describe('Integration with Menu and Item', () => {
it('should render all menu items with correct content', async () => {
// Arrange
const props = createDefaultProps({
breadcrumbs: ['Documents', 'Projects', 'Archive'],
})
render(<Dropdown {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
await waitFor(() => {
expect(screen.getByText('Documents')).toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
@@ -766,7 +657,6 @@ describe('Dropdown', () => {
})
it('should handle click on any menu item', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 0,
@@ -787,7 +677,6 @@ describe('Dropdown', () => {
})
it('should close menu after any item click', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
breadcrumbs: ['item1', 'item2', 'item3'],
@@ -811,7 +700,6 @@ describe('Dropdown', () => {
})
it('should correctly calculate index for each item based on startIndex', async () => {
// Arrange
const mockOnBreadcrumbClick = vi.fn()
const props = createDefaultProps({
startIndex: 3,
@@ -836,31 +724,22 @@ describe('Dropdown', () => {
})
})
// ==========================================
// Accessibility Tests
// ==========================================
describe('Accessibility', () => {
it('should render trigger as button element', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button.tagName).toBe('BUTTON')
})
it('should have type="button" attribute', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Dropdown {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveAttribute('type', 'button')
})

View File

@@ -0,0 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from '../item'
describe('Item', () => {
const defaultProps = {
name: 'Documents',
index: 2,
onBreadcrumbClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify the breadcrumb name is displayed
describe('Rendering', () => {
it('should render breadcrumb name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
})
// User interactions: clicking triggers callback with correct index
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index on click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
})
it('should pass different index values correctly', () => {
render(<Item {...defaultProps} index={5} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(5)
})
})
})

View File

@@ -0,0 +1,79 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Menu from '../menu'
describe('Menu', () => {
const defaultProps = {
breadcrumbs: ['Folder A', 'Folder B', 'Folder C'],
startIndex: 1,
onBreadcrumbClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify all breadcrumb items are displayed
describe('Rendering', () => {
it('should render all breadcrumb items', () => {
render(<Menu {...defaultProps} />)
expect(screen.getByText('Folder A')).toBeInTheDocument()
expect(screen.getByText('Folder B')).toBeInTheDocument()
expect(screen.getByText('Folder C')).toBeInTheDocument()
})
it('should render empty list when no breadcrumbs provided', () => {
const { container } = render(
<Menu breadcrumbs={[]} startIndex={0} onBreadcrumbClick={vi.fn()} />,
)
const menuContainer = container.firstElementChild
expect(menuContainer?.children).toHaveLength(0)
})
})
// Index mapping: startIndex offsets are applied correctly
describe('Index Mapping', () => {
it('should pass correct index (startIndex + offset) to each item', () => {
render(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder A'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
fireEvent.click(screen.getByText('Folder B'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
fireEvent.click(screen.getByText('Folder C'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(3)
})
it('should offset from startIndex of zero', () => {
render(
<Menu
breadcrumbs={['First', 'Second']}
startIndex={0}
onBreadcrumbClick={defaultProps.onBreadcrumbClick}
/>,
)
fireEvent.click(screen.getByText('First'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(0)
fireEvent.click(screen.getByText('Second'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
})
})
// User interactions: clicking items triggers the callback
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index when item clicked', () => {
render(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder B'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
})
})
})

View File

@@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import EmptyFolder from '../empty-folder'
describe('EmptyFolder', () => {
it('should render empty folder message', () => {
render(<EmptyFolder />)
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EmptySearchResult from '../empty-search-result'
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
SearchMenu: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="search-icon" {...props} />,
}))
describe('EmptySearchResult', () => {
const onResetKeywords = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render empty state message', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument()
})
it('should render reset button', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
expect(screen.getByText('datasetPipeline.onlineDrive.resetKeywords')).toBeInTheDocument()
})
it('should call onResetKeywords when reset button clicked', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
fireEvent.click(screen.getByText('datasetPipeline.onlineDrive.resetKeywords'))
expect(onResetKeywords).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import FileIcon from '../file-icon'
vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({
default: ({ type }: { type: string }) => <span data-testid="file-type-icon">{type}</span>,
}))
vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({
BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="bucket-icon" {...props} />,
Folder: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="folder-icon" {...props} />,
}))
describe('FileIcon', () => {
it('should render bucket icon for bucket type', () => {
render(<FileIcon type={OnlineDriveFileType.bucket} fileName="" />)
expect(screen.getByTestId('bucket-icon')).toBeInTheDocument()
})
it('should render folder icon for folder type', () => {
render(<FileIcon type={OnlineDriveFileType.folder} fileName="" />)
expect(screen.getByTestId('folder-icon')).toBeInTheDocument()
})
it('should render file type icon for file type', () => {
render(<FileIcon type={OnlineDriveFileType.file} fileName="doc.pdf" />)
expect(screen.getByTestId('file-type-icon')).toBeInTheDocument()
})
})

View File

@@ -3,16 +3,10 @@ import type { OnlineDriveFile } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { OnlineDriveFileType } from '@/models/pipeline'
import List from './index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
import List from '../index'
// Mock Item component for List tests - child component with complex behavior
vi.mock('./item', () => ({
vi.mock('../item', () => ({
default: ({ file, isSelected, onSelect, onOpen, isMultipleChoice }: {
file: OnlineDriveFile
isSelected: boolean
@@ -35,14 +29,14 @@ vi.mock('./item', () => ({
}))
// Mock EmptyFolder component for List tests
vi.mock('./empty-folder', () => ({
vi.mock('../empty-folder', () => ({
default: () => (
<div data-testid="empty-folder">Empty Folder</div>
),
}))
// Mock EmptySearchResult component for List tests
vi.mock('./empty-search-result', () => ({
vi.mock('../empty-search-result', () => ({
default: ({ onResetKeywords }: { onResetKeywords: () => void }) => (
<div data-testid="empty-search-result">
<span>No results</span>
@@ -53,7 +47,7 @@ vi.mock('./empty-search-result', () => ({
// Mock store state and refs
const mockIsTruncated = { current: false }
const mockCurrentNextPageParametersRef = { current: {} as Record<string, any> }
const mockCurrentNextPageParametersRef = { current: {} as Record<string, unknown> }
const mockSetNextPageParameters = vi.fn()
const mockStoreState = {
@@ -65,13 +59,10 @@ const mockStoreState = {
const mockGetState = vi.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
vi.mock('../../../store', () => ({
vi.mock('../../../../store', () => ({
useDataSourceStore: () => mockDataSourceStore,
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({
id: 'file-1',
name: 'test-file.txt',
@@ -102,9 +93,7 @@ const createDefaultProps = (overrides?: Partial<ListProps>): ListProps => ({
...overrides,
})
// ==========================================
// Mock IntersectionObserver
// ==========================================
let mockIntersectionObserverCallback: IntersectionObserverCallback | null = null
let mockIntersectionObserverInstance: {
observe: Mock
@@ -136,9 +125,6 @@ const createMockIntersectionObserver = () => {
}
}
// ==========================================
// Helper Functions
// ==========================================
const triggerIntersection = (isIntersecting: boolean) => {
if (mockIntersectionObserverCallback) {
const entries = [{
@@ -161,9 +147,6 @@ const resetMockStoreState = () => {
mockGetState.mockClear()
}
// ==========================================
// Test Suites
// ==========================================
describe('List', () => {
const originalIntersectionObserver = window.IntersectionObserver
@@ -181,89 +164,69 @@ describe('List', () => {
window.IntersectionObserver = originalIntersectionObserver
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<List {...props} />)
// Assert
expect(document.body).toBeInTheDocument()
})
it('should render Loading component when isAllLoading is true', () => {
// Arrange
const props = createDefaultProps({
isLoading: true,
fileList: [],
keywords: '',
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render EmptyFolder when folder is empty and not loading', () => {
// Arrange
const props = createDefaultProps({
isLoading: false,
fileList: [],
keywords: '',
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('empty-folder')).toBeInTheDocument()
})
it('should render EmptySearchResult when search has no results', () => {
// Arrange
const props = createDefaultProps({
isLoading: false,
fileList: [],
keywords: 'non-existent-file',
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('empty-search-result')).toBeInTheDocument()
})
it('should render file list when files exist', () => {
// Arrange
const fileList = createMockFileList(3)
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
expect(screen.getByTestId('item-file-2')).toBeInTheDocument()
expect(screen.getByTestId('item-file-3')).toBeInTheDocument()
})
it('should render partial loading spinner when loading more files', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({
fileList,
isLoading: true,
})
// Act
render(<List {...props} />)
// Assert - Should show files AND loading indicator
@@ -272,20 +235,14 @@ describe('List', () => {
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('fileList prop', () => {
it('should render all files from fileList', () => {
// Arrange
const fileList = createMockFileList(5)
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
fileList.forEach((file) => {
expect(screen.getByTestId(`item-${file.id}`)).toBeInTheDocument()
expect(screen.getByTestId(`item-name-${file.id}`)).toHaveTextContent(file.name)
@@ -293,37 +250,28 @@ describe('List', () => {
})
it('should handle empty fileList', () => {
// Arrange
const props = createDefaultProps({ fileList: [] })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('empty-folder')).toBeInTheDocument()
})
it('should handle single file in fileList', () => {
// Arrange
const fileList = [createMockOnlineDriveFile()]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
})
it('should handle large fileList', () => {
// Arrange
const fileList = createMockFileList(100)
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
expect(screen.getByTestId('item-file-100')).toBeInTheDocument()
})
@@ -331,51 +279,42 @@ describe('List', () => {
describe('selectedFileIds prop', () => {
it('should mark selected files as selected', () => {
// Arrange
const fileList = createMockFileList(3)
const props = createDefaultProps({
fileList,
selectedFileIds: ['file-1', 'file-3'],
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true')
expect(screen.getByTestId('item-file-2')).toHaveAttribute('data-selected', 'false')
expect(screen.getByTestId('item-file-3')).toHaveAttribute('data-selected', 'true')
})
it('should handle empty selectedFileIds', () => {
// Arrange
const fileList = createMockFileList(3)
const props = createDefaultProps({
fileList,
selectedFileIds: [],
})
// Act
render(<List {...props} />)
// Assert
fileList.forEach((file) => {
expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'false')
})
})
it('should handle all files selected', () => {
// Arrange
const fileList = createMockFileList(3)
const props = createDefaultProps({
fileList,
selectedFileIds: ['file-1', 'file-2', 'file-3'],
})
// Act
render(<List {...props} />)
// Assert
fileList.forEach((file) => {
expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'true')
})
@@ -384,30 +323,24 @@ describe('List', () => {
describe('keywords prop', () => {
it('should show EmptySearchResult when keywords exist but no results', () => {
// Arrange
const props = createDefaultProps({
fileList: [],
keywords: 'search-term',
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('empty-search-result')).toBeInTheDocument()
})
it('should show EmptyFolder when keywords is empty and no files', () => {
// Arrange
const props = createDefaultProps({
fileList: [],
keywords: '',
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('empty-folder')).toBeInTheDocument()
})
})
@@ -419,13 +352,10 @@ describe('List', () => {
{ isLoading: false, fileList: [], keywords: '', expected: 'isEmpty' },
{ isLoading: false, fileList: createMockFileList(2), keywords: '', expected: 'hasFiles' },
])('should render correctly when isLoading=$isLoading with fileList.length=$fileList.length', ({ isLoading, fileList, expected }) => {
// Arrange
const props = createDefaultProps({ isLoading, fileList })
// Act
render(<List {...props} />)
// Assert
switch (expected) {
case 'isAllLoading':
expect(screen.getByRole('status')).toBeInTheDocument()
@@ -446,44 +376,35 @@ describe('List', () => {
describe('supportBatchUpload prop', () => {
it('should pass supportBatchUpload true to Item components', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({
fileList,
supportBatchUpload: true,
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'true')
})
it('should pass supportBatchUpload false to Item components', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({
fileList,
supportBatchUpload: false,
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'false')
})
})
})
// ==========================================
// User Interactions and Event Handlers
// ==========================================
describe('User Interactions', () => {
describe('File Selection', () => {
it('should call handleSelectFile when selecting a file', () => {
// Arrange
const handleSelectFile = vi.fn()
const fileList = createMockFileList(2)
const props = createDefaultProps({
@@ -492,15 +413,12 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
fireEvent.click(screen.getByTestId('item-select-file-1'))
// Assert
expect(handleSelectFile).toHaveBeenCalledWith(fileList[0])
})
it('should call handleSelectFile with correct file data', () => {
// Arrange
const handleSelectFile = vi.fn()
const fileList = [
createMockOnlineDriveFile({ id: 'unique-id', name: 'special-file.pdf', size: 5000 }),
@@ -511,10 +429,8 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
fireEvent.click(screen.getByTestId('item-select-unique-id'))
// Assert
expect(handleSelectFile).toHaveBeenCalledWith(
expect.objectContaining({
id: 'unique-id',
@@ -527,7 +443,6 @@ describe('List', () => {
describe('Folder Navigation', () => {
it('should call handleOpenFolder when opening a folder', () => {
// Arrange
const handleOpenFolder = vi.fn()
const fileList = [
createMockOnlineDriveFile({ id: 'folder-1', name: 'Documents', type: OnlineDriveFileType.folder }),
@@ -538,17 +453,14 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
fireEvent.click(screen.getByTestId('item-open-folder-1'))
// Assert
expect(handleOpenFolder).toHaveBeenCalledWith(fileList[0])
})
})
describe('Reset Keywords', () => {
it('should call handleResetKeywords when reset button is clicked', () => {
// Arrange
const handleResetKeywords = vi.fn()
const props = createDefaultProps({
fileList: [],
@@ -557,38 +469,29 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
fireEvent.click(screen.getByTestId('reset-keywords-btn'))
// Assert
expect(handleResetKeywords).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// Side Effects and Cleanup Tests (IntersectionObserver)
// ==========================================
describe('Side Effects and Cleanup', () => {
describe('IntersectionObserver Setup', () => {
it('should create IntersectionObserver on mount', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled()
})
it('should create IntersectionObserver with correct rootMargin', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert - Callback should be set
@@ -596,14 +499,11 @@ describe('List', () => {
})
it('should observe the anchor element', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled()
const observedElement = mockIntersectionObserverInstance?.observe.mock.calls[0]?.[0]
expect(observedElement).toBeInstanceOf(HTMLElement)
@@ -613,7 +513,6 @@ describe('List', () => {
describe('IntersectionObserver Callback', () => {
it('should call setNextPageParameters when intersecting and truncated', async () => {
// Arrange
mockIsTruncated.current = true
mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' }
const fileList = createMockFileList(2)
@@ -623,17 +522,14 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
triggerIntersection(true)
// Assert
await waitFor(() => {
expect(mockSetNextPageParameters).toHaveBeenCalledWith({ cursor: 'next-cursor' })
})
})
it('should not call setNextPageParameters when not intersecting', () => {
// Arrange
mockIsTruncated.current = true
mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' }
const fileList = createMockFileList(2)
@@ -643,15 +539,12 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
triggerIntersection(false)
// Assert
expect(mockSetNextPageParameters).not.toHaveBeenCalled()
})
it('should not call setNextPageParameters when not truncated', () => {
// Arrange
mockIsTruncated.current = false
const fileList = createMockFileList(2)
const props = createDefaultProps({
@@ -660,15 +553,12 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
triggerIntersection(true)
// Assert
expect(mockSetNextPageParameters).not.toHaveBeenCalled()
})
it('should not call setNextPageParameters when loading', () => {
// Arrange
mockIsTruncated.current = true
mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' }
const fileList = createMockFileList(2)
@@ -678,30 +568,24 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
triggerIntersection(true)
// Assert
expect(mockSetNextPageParameters).not.toHaveBeenCalled()
})
})
describe('IntersectionObserver Cleanup', () => {
it('should disconnect IntersectionObserver on unmount', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({ fileList })
const { unmount } = render(<List {...props} />)
// Act
unmount()
// Assert
expect(mockIntersectionObserverInstance?.disconnect).toHaveBeenCalled()
})
it('should cleanup previous observer when dependencies change', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({
fileList,
@@ -718,18 +602,14 @@ describe('List', () => {
})
})
// ==========================================
// Component Memoization Tests
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Arrange & Assert
// List component should have $$typeof symbol indicating memo wrapper
expect(List).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should not re-render when props are equal', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({ fileList })
const renderSpy = vi.fn()
@@ -751,7 +631,6 @@ describe('List', () => {
})
it('should re-render when fileList changes', () => {
// Arrange
const fileList1 = createMockFileList(2)
const fileList2 = createMockFileList(3)
const props1 = createDefaultProps({ fileList: fileList1 })
@@ -772,7 +651,6 @@ describe('List', () => {
})
it('should re-render when selectedFileIds changes', () => {
// Arrange
const fileList = createMockFileList(2)
const props1 = createDefaultProps({ fileList, selectedFileIds: [] })
const props2 = createDefaultProps({ fileList, selectedFileIds: ['file-1'] })
@@ -782,15 +660,12 @@ describe('List', () => {
// Assert initial state
expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false')
// Act
rerender(<List {...props2} />)
// Assert
expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true')
})
it('should re-render when isLoading changes', () => {
// Arrange
const fileList = createMockFileList(2)
const props1 = createDefaultProps({ fileList, isLoading: false })
const props2 = createDefaultProps({ fileList, isLoading: true })
@@ -800,7 +675,6 @@ describe('List', () => {
// Assert initial state - no loading spinner
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// Act
rerender(<List {...props2} />)
// Assert - loading spinner should appear
@@ -808,45 +682,34 @@ describe('List', () => {
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
describe('Empty/Null Values', () => {
it('should handle empty fileList array', () => {
// Arrange
const props = createDefaultProps({ fileList: [] })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('empty-folder')).toBeInTheDocument()
})
it('should handle empty selectedFileIds array', () => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({
fileList,
selectedFileIds: [],
})
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false')
})
it('should handle empty keywords string', () => {
// Arrange
const props = createDefaultProps({
fileList: [],
keywords: '',
})
// Act
render(<List {...props} />)
// Assert - Shows empty folder, not empty search result
@@ -857,65 +720,50 @@ describe('List', () => {
describe('Boundary Conditions', () => {
it('should handle very long file names', () => {
// Arrange
const longName = `${'a'.repeat(500)}.txt`
const fileList = [createMockOnlineDriveFile({ name: longName })]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(longName)
})
it('should handle special characters in file names', () => {
// Arrange
const specialName = 'test<script>alert("xss")</script>.txt'
const fileList = [createMockOnlineDriveFile({ name: specialName })]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(specialName)
})
it('should handle unicode characters in file names', () => {
// Arrange
const unicodeName = '文件_📁_ファイル.txt'
const fileList = [createMockOnlineDriveFile({ name: unicodeName })]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(unicodeName)
})
it('should handle file with zero size', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ size: 0 })]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
})
it('should handle file with undefined size', () => {
// Arrange
const fileList = [createMockOnlineDriveFile({ size: undefined })]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
})
})
@@ -926,20 +774,16 @@ describe('List', () => {
{ type: OnlineDriveFileType.folder, name: 'Documents' },
{ type: OnlineDriveFileType.bucket, name: 'my-bucket' },
])('should render $type type correctly', ({ type, name }) => {
// Arrange
const fileList = [createMockOnlineDriveFile({ id: `item-${type}`, type, name })]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId(`item-item-${type}`)).toBeInTheDocument()
expect(screen.getByTestId(`item-name-item-${type}`)).toHaveTextContent(name)
})
it('should handle mixed file types in list', () => {
// Arrange
const fileList = [
createMockOnlineDriveFile({ id: 'file-1', type: OnlineDriveFileType.file, name: 'doc.pdf' }),
createMockOnlineDriveFile({ id: 'folder-1', type: OnlineDriveFileType.folder, name: 'Documents' }),
@@ -947,10 +791,8 @@ describe('List', () => {
]
const props = createDefaultProps({ fileList })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
expect(screen.getByTestId('item-folder-1')).toBeInTheDocument()
expect(screen.getByTestId('item-bucket-1')).toBeInTheDocument()
@@ -959,7 +801,6 @@ describe('List', () => {
describe('Loading States Transitions', () => {
it('should transition from loading to empty folder', () => {
// Arrange
const props1 = createDefaultProps({ isLoading: true, fileList: [] })
const props2 = createDefaultProps({ isLoading: false, fileList: [] })
@@ -968,16 +809,13 @@ describe('List', () => {
// Assert initial loading state
expect(screen.getByRole('status')).toBeInTheDocument()
// Act
rerender(<List {...props2} />)
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByTestId('empty-folder')).toBeInTheDocument()
})
it('should transition from loading to file list', () => {
// Arrange
const fileList = createMockFileList(2)
const props1 = createDefaultProps({ isLoading: true, fileList: [] })
const props2 = createDefaultProps({ isLoading: false, fileList })
@@ -987,16 +825,13 @@ describe('List', () => {
// Assert initial loading state
expect(screen.getByRole('status')).toBeInTheDocument()
// Act
rerender(<List {...props2} />)
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByTestId('item-file-1')).toBeInTheDocument()
})
it('should transition from partial loading to loaded', () => {
// Arrange
const fileList = createMockFileList(2)
const props1 = createDefaultProps({ isLoading: true, fileList })
const props2 = createDefaultProps({ isLoading: false, fileList })
@@ -1006,17 +841,14 @@ describe('List', () => {
// Assert initial partial loading state
expect(screen.getByRole('status')).toBeInTheDocument()
// Act
rerender(<List {...props2} />)
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
describe('Store State Edge Cases', () => {
it('should handle store state with empty next page parameters', () => {
// Arrange
mockIsTruncated.current = true
mockCurrentNextPageParametersRef.current = {}
const fileList = createMockFileList(2)
@@ -1026,15 +858,12 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
triggerIntersection(true)
// Assert
expect(mockSetNextPageParameters).toHaveBeenCalledWith({})
})
it('should handle store state with complex next page parameters', () => {
// Arrange
const complexParams = {
cursor: 'abc123',
page: 2,
@@ -1049,31 +878,23 @@ describe('List', () => {
})
render(<List {...props} />)
// Act
triggerIntersection(true)
// Assert
expect(mockSetNextPageParameters).toHaveBeenCalledWith(complexParams)
})
})
})
// ==========================================
// All Prop Variations Tests
// ==========================================
describe('Prop Variations', () => {
it.each([
{ supportBatchUpload: true },
{ supportBatchUpload: false },
])('should render correctly with supportBatchUpload=$supportBatchUpload', ({ supportBatchUpload }) => {
// Arrange
const fileList = createMockFileList(2)
const props = createDefaultProps({ fileList, supportBatchUpload })
// Act
render(<List {...props} />)
// Assert
expect(screen.getByTestId('item-file-1')).toHaveAttribute(
'data-multiple-choice',
String(supportBatchUpload),
@@ -1087,14 +908,11 @@ describe('List', () => {
{ isLoading: false, fileCount: 0, keywords: 'search', expectedState: 'empty-search' },
{ isLoading: false, fileCount: 5, keywords: '', expectedState: 'file-list' },
])('should render $expectedState when isLoading=$isLoading, fileCount=$fileCount, keywords=$keywords', ({ isLoading, fileCount, keywords, expectedState }) => {
// Arrange
const fileList = createMockFileList(fileCount)
const props = createDefaultProps({ fileList, isLoading, keywords })
// Act
render(<List {...props} />)
// Assert
switch (expectedState) {
case 'all-loading':
expect(screen.getByRole('status')).toBeInTheDocument()
@@ -1120,17 +938,14 @@ describe('List', () => {
{ selectedCount: 1, expectedSelected: ['file-1'] },
{ selectedCount: 3, expectedSelected: ['file-1', 'file-2', 'file-3'] },
])('should handle $selectedCount selected files', ({ expectedSelected }) => {
// Arrange
const fileList = createMockFileList(3)
const props = createDefaultProps({
fileList,
selectedFileIds: expectedSelected,
})
// Act
render(<List {...props} />)
// Assert
fileList.forEach((file) => {
const isSelected = expectedSelected.includes(file.id)
expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', String(isSelected))
@@ -1138,12 +953,8 @@ describe('List', () => {
})
})
// ==========================================
// Accessibility Tests
// ==========================================
describe('Accessibility', () => {
it('should allow interaction with reset keywords button in empty search state', () => {
// Arrange
const handleResetKeywords = vi.fn()
const props = createDefaultProps({
fileList: [],
@@ -1151,11 +962,9 @@ describe('List', () => {
handleResetKeywords,
})
// Act
render(<List {...props} />)
const resetButton = screen.getByTestId('reset-keywords-btn')
// Assert
expect(resetButton).toBeInTheDocument()
fireEvent.click(resetButton)
expect(handleResetKeywords).toHaveBeenCalled()
@@ -1163,15 +972,13 @@ describe('List', () => {
})
})
// ==========================================
// EmptyFolder Component Tests (using actual component)
// ==========================================
describe('EmptyFolder', () => {
// Get real component for testing
let ActualEmptyFolder: React.ComponentType
beforeAll(async () => {
const mod = await vi.importActual<{ default: React.ComponentType }>('./empty-folder')
const mod = await vi.importActual<{ default: React.ComponentType }>('../empty-folder')
ActualEmptyFolder = mod.default
})
@@ -1206,15 +1013,13 @@ describe('EmptyFolder', () => {
})
})
// ==========================================
// EmptySearchResult Component Tests (using actual component)
// ==========================================
describe('EmptySearchResult', () => {
// Get real component for testing
let ActualEmptySearchResult: React.ComponentType<{ onResetKeywords: () => void }>
beforeAll(async () => {
const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('./empty-search-result')
const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('../empty-search-result')
ActualEmptySearchResult = mod.default
})
@@ -1292,16 +1097,14 @@ describe('EmptySearchResult', () => {
})
})
// ==========================================
// FileIcon Component Tests (using actual component)
// ==========================================
describe('FileIcon', () => {
// Get real component for testing
type FileIconProps = { type: OnlineDriveFileType, fileName: string, size?: 'sm' | 'md' | 'lg' | 'xl', className?: string }
let ActualFileIcon: React.ComponentType<FileIconProps>
beforeAll(async () => {
const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('./file-icon')
const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('../file-icon')
ActualFileIcon = mod.default
})
@@ -1455,9 +1258,7 @@ describe('FileIcon', () => {
})
})
// ==========================================
// Item Component Tests (using actual component)
// ==========================================
describe('Item', () => {
// Get real component for testing
let ActualItem: React.ComponentType<ItemProps>
@@ -1472,7 +1273,7 @@ describe('Item', () => {
}
beforeAll(async () => {
const mod = await vi.importActual<{ default: React.ComponentType<ItemProps> }>('./item')
const mod = await vi.importActual<{ default: React.ComponentType<ItemProps> }>('../item')
ActualItem = mod.default
})
@@ -1746,9 +1547,7 @@ describe('Item', () => {
})
})
// ==========================================
// Utils Tests
// ==========================================
describe('utils', () => {
// Import actual utils functions
let getFileExtension: (filename: string) => string
@@ -1756,7 +1555,7 @@ describe('utils', () => {
let FileAppearanceTypeEnum: Record<string, string>
beforeAll(async () => {
const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('./utils')
const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('../utils')
const types = await vi.importActual<{ FileAppearanceTypeEnum: typeof FileAppearanceTypeEnum }>('@/app/components/base/file-uploader/types')
getFileExtension = utils.getFileExtension
getFileType = utils.getFileType

View File

@@ -0,0 +1,90 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from '../item'
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck, disabled }: { checked: boolean, onCheck: () => void, disabled?: boolean }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} disabled={disabled} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" title={popupContent}>{children}</div>
),
}))
vi.mock('../file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
describe('Item', () => {
const makeFile = (type: string, name = 'test.pdf', size = 1024): OnlineDriveFile => ({
id: 'f-1',
name,
type: type as OnlineDriveFile['type'],
size,
})
const defaultProps = {
file: makeFile('file'),
isSelected: false,
onSelect: vi.fn(),
onOpen: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render file name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render checkbox for file type in multiple choice mode', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio for file type in single choice mode', () => {
render(<Item {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should not render checkbox for bucket type', () => {
render(<Item {...defaultProps} file={makeFile('bucket', 'my-bucket')} />)
expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument()
})
it('should call onOpen for folder click', () => {
const file = makeFile('folder', 'my-folder')
render(<Item {...defaultProps} file={file} />)
fireEvent.click(screen.getByText('my-folder'))
expect(defaultProps.onOpen).toHaveBeenCalledWith(file)
})
it('should call onSelect for file click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).toHaveBeenCalledWith(defaultProps.file)
})
it('should not call handlers when disabled', () => {
render(<Item {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).not.toHaveBeenCalled()
})
it('should render file icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
import { getFileExtension, getFileType } from '../utils'
describe('getFileExtension', () => {
it('should return extension for normal file', () => {
expect(getFileExtension('test.pdf')).toBe('pdf')
})
it('should return lowercase extension', () => {
expect(getFileExtension('test.PDF')).toBe('pdf')
})
it('should return last extension for multiple dots', () => {
expect(getFileExtension('my.file.name.txt')).toBe('txt')
})
it('should return empty string for no extension', () => {
expect(getFileExtension('noext')).toBe('')
})
it('should return empty string for empty string', () => {
expect(getFileExtension('')).toBe('')
})
it('should return empty string for dotfile with no extension', () => {
expect(getFileExtension('.gitignore')).toBe('')
})
})
describe('getFileType', () => {
it('should return pdf for .pdf files', () => {
expect(getFileType('doc.pdf')).toBe(FileAppearanceTypeEnum.pdf)
})
it('should return markdown for .md files', () => {
expect(getFileType('readme.md')).toBe(FileAppearanceTypeEnum.markdown)
})
it('should return markdown for .mdx files', () => {
expect(getFileType('page.mdx')).toBe(FileAppearanceTypeEnum.markdown)
})
it('should return excel for .xlsx files', () => {
expect(getFileType('data.xlsx')).toBe(FileAppearanceTypeEnum.excel)
})
it('should return excel for .csv files', () => {
expect(getFileType('data.csv')).toBe(FileAppearanceTypeEnum.excel)
})
it('should return word for .docx files', () => {
expect(getFileType('doc.docx')).toBe(FileAppearanceTypeEnum.word)
})
it('should return ppt for .pptx files', () => {
expect(getFileType('slides.pptx')).toBe(FileAppearanceTypeEnum.ppt)
})
it('should return code for .html files', () => {
expect(getFileType('page.html')).toBe(FileAppearanceTypeEnum.code)
})
it('should return code for .json files', () => {
expect(getFileType('config.json')).toBe(FileAppearanceTypeEnum.code)
})
it('should return gif for .gif files', () => {
expect(getFileType('animation.gif')).toBe(FileAppearanceTypeEnum.gif)
})
it('should return custom for unknown extension', () => {
expect(getFileType('file.xyz')).toBe(FileAppearanceTypeEnum.custom)
})
it('should return custom for no extension', () => {
expect(getFileType('noext')).toBe(FileAppearanceTypeEnum.custom)
})
})

View File

@@ -1,38 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render without crashing', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@@ -0,0 +1,96 @@
import type { FileItem } from '@/models/datasets'
import { render, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import { createDataSourceStore, useDataSourceStore, useDataSourceStoreWithSelector } from '../'
import DataSourceProvider from '../provider'
describe('createDataSourceStore', () => {
it('should create a store with all slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice
expect(state.currentCredentialId).toBe('')
expect(typeof state.setCurrentCredentialId).toBe('function')
// LocalFile slice
expect(state.localFileList).toEqual([])
expect(typeof state.setLocalFileList).toBe('function')
// OnlineDocument slice
expect(state.documentsData).toEqual([])
expect(typeof state.setDocumentsData).toBe('function')
// WebsiteCrawl slice
expect(state.websitePages).toEqual([])
expect(typeof state.setWebsitePages).toBe('function')
// OnlineDrive slice
expect(state.breadcrumbs).toEqual([])
expect(typeof state.setBreadcrumbs).toBe('function')
})
it('should allow cross-slice state updates', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-1')
store.getState().setLocalFileList([{ file: { id: 'f1' } }] as unknown as FileItem[])
expect(store.getState().currentCredentialId).toBe('cred-1')
expect(store.getState().localFileList).toHaveLength(1)
})
it('should create independent store instances', () => {
const store1 = createDataSourceStore()
const store2 = createDataSourceStore()
store1.getState().setCurrentCredentialId('cred-1')
expect(store2.getState().currentCredentialId).toBe('')
})
})
describe('useDataSourceStoreWithSelector', () => {
it('should throw when used outside provider', () => {
expect(() => {
renderHook(() => useDataSourceStoreWithSelector(s => s.currentCredentialId))
}).toThrow('Missing DataSourceContext.Provider in the tree')
})
it('should return selected state when used inside provider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(DataSourceProvider, null, children)
const { result } = renderHook(
() => useDataSourceStoreWithSelector(s => s.currentCredentialId),
{ wrapper },
)
expect(result.current).toBe('')
})
})
describe('useDataSourceStore', () => {
it('should throw when used outside provider', () => {
expect(() => {
renderHook(() => useDataSourceStore())
}).toThrow('Missing DataSourceContext.Provider in the tree')
})
it('should return store when used inside provider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(DataSourceProvider, null, children)
const { result } = renderHook(
() => useDataSourceStore(),
{ wrapper },
)
expect(result.current).toBeDefined()
expect(typeof result.current.getState).toBe('function')
})
})
describe('DataSourceProvider', () => {
it('should render children', () => {
const child = React.createElement('div', null, 'Child Content')
const { getByText } = render(React.createElement(DataSourceProvider, null, child))
expect(getByText('Child Content')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import { useContext } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DataSourceProvider, { DataSourceContext } from '../provider'
const mockStore = { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn() }
vi.mock('../', () => ({
createDataSourceStore: () => mockStore,
}))
// Test consumer component that reads from context
function ContextConsumer() {
const store = useContext(DataSourceContext)
return (
<div data-testid="context-value" data-has-store={store !== null}>
{store ? 'has-store' : 'no-store'}
</div>
)
}
describe('DataSourceProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verifies children are passed through
describe('Rendering', () => {
it('should render children', () => {
render(
<DataSourceProvider>
<span data-testid="child">Hello</span>
</DataSourceProvider>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
// Context: verifies the store is provided to consumers
describe('Context', () => {
it('should provide store value to context consumers', () => {
render(
<DataSourceProvider>
<ContextConsumer />
</DataSourceProvider>,
)
expect(screen.getByTestId('context-value')).toHaveTextContent('has-store')
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'true')
})
it('should provide null when no provider wraps the consumer', () => {
render(<ContextConsumer />)
expect(screen.getByTestId('context-value')).toHaveTextContent('no-store')
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'false')
})
})
// Stability: verifies the store reference is stable across re-renders
describe('Store Stability', () => {
it('should reuse same store on re-render (stable reference)', () => {
const storeValues: Array<typeof mockStore | null> = []
function StoreCapture() {
const store = useContext(DataSourceContext)
storeValues.push(store as typeof mockStore | null)
return null
}
const { rerender } = render(
<DataSourceProvider>
<StoreCapture />
</DataSourceProvider>,
)
rerender(
<DataSourceProvider>
<StoreCapture />
</DataSourceProvider>,
)
expect(storeValues).toHaveLength(2)
expect(storeValues[0]).toBe(storeValues[1])
})
})
})

View File

@@ -0,0 +1,29 @@
import type { CommonShape } from '../common'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createCommonSlice } from '../common'
const createTestStore = () => createStore<CommonShape>((...args) => createCommonSlice(...args))
describe('createCommonSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
expect(state.currentCredentialIdRef.current).toBe('')
})
it('should update currentCredentialId', () => {
const store = createTestStore()
store.getState().setCurrentCredentialId('cred-123')
expect(store.getState().currentCredentialId).toBe('cred-123')
})
it('should update currentCredentialId multiple times', () => {
const store = createTestStore()
store.getState().setCurrentCredentialId('cred-1')
store.getState().setCurrentCredentialId('cred-2')
expect(store.getState().currentCredentialId).toBe('cred-2')
})
})

View File

@@ -0,0 +1,49 @@
import type { LocalFileSliceShape } from '../local-file'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createLocalFileSlice } from '../local-file'
const createTestStore = () => createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args))
describe('createLocalFileSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
expect(state.previewLocalFileRef.current).toBeUndefined()
})
it('should set local file list and update preview ref to first file', () => {
const store = createTestStore()
const files = [
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: 'f2', name: 'b.pdf' } },
] as unknown as FileItem[]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toEqual(files)
expect(store.getState().previewLocalFileRef.current).toEqual({ id: 'f1', name: 'a.pdf' })
})
it('should set preview ref to undefined for empty file list', () => {
const store = createTestStore()
store.getState().setLocalFileList([])
expect(store.getState().previewLocalFileRef.current).toBeUndefined()
})
it('should set current local file', () => {
const store = createTestStore()
const file = { id: 'f1', name: 'test.pdf' } as unknown as File
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toEqual(file)
})
it('should clear current local file with undefined', () => {
const store = createTestStore()
store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File)
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})

View File

@@ -0,0 +1,55 @@
import type { OnlineDocumentSliceShape } from '../online-document'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createOnlineDocumentSlice } from '../online-document'
const createTestStore = () => createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args))
describe('createOnlineDocumentSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.documentsData).toEqual([])
expect(state.searchValue).toBe('')
expect(state.onlineDocuments).toEqual([])
expect(state.currentDocument).toBeUndefined()
expect(state.selectedPagesId).toEqual(new Set())
expect(state.previewOnlineDocumentRef.current).toBeUndefined()
})
it('should set documents data', () => {
const store = createTestStore()
const data = [{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[]
store.getState().setDocumentsData(data)
expect(store.getState().documentsData).toEqual(data)
})
it('should set search value', () => {
const store = createTestStore()
store.getState().setSearchValue('hello')
expect(store.getState().searchValue).toBe('hello')
})
it('should set online documents and update preview ref', () => {
const store = createTestStore()
const pages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toEqual(pages)
expect(store.getState().previewOnlineDocumentRef.current).toEqual({ page_id: 'p1' })
})
it('should set current document', () => {
const store = createTestStore()
const doc = { page_id: 'p1' } as unknown as NotionPage
store.getState().setCurrentDocument(doc)
expect(store.getState().currentDocument).toEqual(doc)
})
it('should set selected pages id', () => {
const store = createTestStore()
const ids = new Set(['p1', 'p2'])
store.getState().setSelectedPagesId(ids)
expect(store.getState().selectedPagesId).toEqual(ids)
})
})

View File

@@ -0,0 +1,79 @@
import type { OnlineDriveSliceShape } from '../online-drive'
import type { OnlineDriveFile } from '@/models/pipeline'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createOnlineDriveSlice } from '../online-drive'
const createTestStore = () => createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args))
describe('createOnlineDriveSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.breadcrumbs).toEqual([])
expect(state.prefix).toEqual([])
expect(state.keywords).toBe('')
expect(state.selectedFileIds).toEqual([])
expect(state.onlineDriveFileList).toEqual([])
expect(state.bucket).toBe('')
expect(state.nextPageParameters).toEqual({})
expect(state.isTruncated.current).toBe(false)
expect(state.previewOnlineDriveFileRef.current).toBeUndefined()
expect(state.hasBucket).toBe(false)
})
it('should set breadcrumbs', () => {
const store = createTestStore()
store.getState().setBreadcrumbs(['root', 'folder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder'])
})
it('should set prefix', () => {
const store = createTestStore()
store.getState().setPrefix(['a', 'b'])
expect(store.getState().prefix).toEqual(['a', 'b'])
})
it('should set keywords', () => {
const store = createTestStore()
store.getState().setKeywords('search term')
expect(store.getState().keywords).toBe('search term')
})
it('should set selected file ids and update preview ref', () => {
const store = createTestStore()
const files = [
{ id: 'file-1', name: 'a.pdf', type: 'file' },
{ id: 'file-2', name: 'b.pdf', type: 'file' },
] as unknown as OnlineDriveFile[]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['file-1'])
expect(store.getState().selectedFileIds).toEqual(['file-1'])
expect(store.getState().previewOnlineDriveFileRef.current).toEqual(files[0])
})
it('should set preview ref to undefined when selected id not found', () => {
const store = createTestStore()
store.getState().setSelectedFileIds(['non-existent'])
expect(store.getState().previewOnlineDriveFileRef.current).toBeUndefined()
})
it('should set bucket', () => {
const store = createTestStore()
store.getState().setBucket('my-bucket')
expect(store.getState().bucket).toBe('my-bucket')
})
it('should set next page parameters', () => {
const store = createTestStore()
store.getState().setNextPageParameters({ cursor: 'abc' })
expect(store.getState().nextPageParameters).toEqual({ cursor: 'abc' })
})
it('should set hasBucket', () => {
const store = createTestStore()
store.getState().setHasBucket(true)
expect(store.getState().hasBucket).toBe(true)
})
})

View File

@@ -0,0 +1,65 @@
import type { WebsiteCrawlSliceShape } from '../website-crawl'
import type { CrawlResult, CrawlResultItem } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { CrawlStep } from '@/models/datasets'
import { createWebsiteCrawlSlice } from '../website-crawl'
const createTestStore = () => createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args))
describe('createWebsiteCrawlSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.websitePages).toEqual([])
expect(state.currentWebsite).toBeUndefined()
expect(state.crawlResult).toBeUndefined()
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
expect(state.previewWebsitePageRef.current).toBeUndefined()
})
it('should set website pages and update preview ref', () => {
const store = createTestStore()
const pages = [
{ title: 'Page 1', source_url: 'https://a.com' },
{ title: 'Page 2', source_url: 'https://b.com' },
] as unknown as CrawlResultItem[]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toEqual(pages)
expect(store.getState().previewWebsitePageRef.current).toEqual(pages[0])
})
it('should set current website', () => {
const store = createTestStore()
const website = { title: 'Page 1' } as unknown as CrawlResultItem
store.getState().setCurrentWebsite(website)
expect(store.getState().currentWebsite).toEqual(website)
})
it('should set crawl result', () => {
const store = createTestStore()
const result = { data: { count: 5 } } as unknown as CrawlResult
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult).toEqual(result)
})
it('should set step', () => {
const store = createTestStore()
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
})
it('should set preview index', () => {
const store = createTestStore()
store.getState().setPreviewIndex(3)
expect(store.getState().previewIndex).toBe(3)
})
it('should clear current website with undefined', () => {
const store = createTestStore()
store.getState().setCurrentWebsite({ title: 'X' } as unknown as CrawlResultItem)
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})

View File

@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from '../checkbox-with-label'
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
onChange: vi.fn(),
label: 'Test Label',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label text', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render checkbox', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,69 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from '../crawled-result-item'
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<button data-testid="preview-button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: {
title: 'Test Page',
source_url: 'https://example.com/page',
markdown: '',
description: '',
} satisfies CrawlResultItemType,
isChecked: false,
onCheckChange: vi.fn(),
isPreview: false,
showPreview: true,
onPreview: vi.fn(),
isMultipleChoice: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title and URL', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render checkbox in multiple choice mode', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio in single choice mode', () => {
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should show preview button when showPreview is true', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('preview-button')).toBeInTheDocument()
})
it('should not show preview button when showPreview is false', () => {
render(<CrawledResultItem {...defaultProps} showPreview={false} />)
expect(screen.queryByTestId('preview-button')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,214 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import CrawledResult from '../crawled-result'
vi.mock('../checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="check-all-checkbox"
/>
{label}
</label>
),
}))
vi.mock('../crawled-result-item', () => ({
default: ({
payload,
isChecked,
onCheckChange,
onPreview,
}: {
payload: CrawlResultItem
isChecked: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
}) => (
<div data-testid={`crawled-item-${payload.source_url}`}>
<span data-testid="item-url">{payload.source_url}</span>
<button data-testid={`check-${payload.source_url}`} onClick={() => onCheckChange(!isChecked)}>
{isChecked ? 'uncheck' : 'check'}
</button>
<button data-testid={`preview-${payload.source_url}`} onClick={onPreview}>
preview
</button>
</div>
),
}))
const createItem = (url: string): CrawlResultItem => ({
source_url: url,
title: `Title for ${url}`,
markdown: `# ${url}`,
description: `Desc for ${url}`,
})
const defaultList: CrawlResultItem[] = [
createItem('https://example.com/a'),
createItem('https://example.com/b'),
createItem('https://example.com/c'),
]
describe('CrawledResult', () => {
const defaultProps = {
list: defaultList,
checkedList: [] as CrawlResultItem[],
onSelectedChange: vi.fn(),
usedTime: 12.345,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render scrap time info with correct total and time', () => {
render(<CrawledResult {...defaultProps} />)
expect(
screen.getByText(/scrapTimeInfo/),
).toBeInTheDocument()
// The global i18n mock serialises params, so verify total and time appear
expect(screen.getByText(/"total":3/)).toBeInTheDocument()
expect(screen.getByText(/"time":"12.3"/)).toBeInTheDocument()
})
it('should render all items from list', () => {
render(<CrawledResult {...defaultProps} />)
for (const item of defaultList) {
expect(screen.getByTestId(`crawled-item-${item.source_url}`)).toBeInTheDocument()
}
})
it('should apply custom className', () => {
const { container } = render(
<CrawledResult {...defaultProps} className="my-custom-class" />,
)
expect(container.firstChild).toHaveClass('my-custom-class')
})
})
// Check-all checkbox visibility
describe('Check All Checkbox', () => {
it('should show check-all checkbox in multiple choice mode', () => {
render(<CrawledResult {...defaultProps} isMultipleChoice={true} />)
expect(screen.getByTestId('check-all-checkbox')).toBeInTheDocument()
})
it('should hide check-all checkbox in single choice mode', () => {
render(<CrawledResult {...defaultProps} isMultipleChoice={false} />)
expect(screen.queryByTestId('check-all-checkbox')).not.toBeInTheDocument()
})
})
// Toggle all items
describe('Toggle All', () => {
it('should select all when not all checked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
/>,
)
fireEvent.click(screen.getByTestId('check-all-checkbox'))
expect(onSelectedChange).toHaveBeenCalledWith(defaultList)
})
it('should deselect all when all checked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[...defaultList]}
onSelectedChange={onSelectedChange}
/>,
)
fireEvent.click(screen.getByTestId('check-all-checkbox'))
expect(onSelectedChange).toHaveBeenCalledWith([])
})
})
// Individual item check
describe('Individual Item Check', () => {
it('should add item to selection in multiple choice mode', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={true}
/>,
)
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[0], defaultList[1]])
})
it('should replace selection in single choice mode', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={false}
/>,
)
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
})
it('should remove item from selection when unchecked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0], defaultList[1]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={true}
/>,
)
fireEvent.click(screen.getByTestId(`check-${defaultList[0].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
})
})
// Preview
describe('Preview', () => {
it('should call onPreview with correct item and index', () => {
const onPreview = vi.fn()
render(
<CrawledResult
{...defaultProps}
onPreview={onPreview}
showPreview={true}
/>,
)
fireEvent.click(screen.getByTestId(`preview-${defaultList[1].source_url}`))
expect(onPreview).toHaveBeenCalledWith(defaultList[1], 1)
})
})
})

View File

@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Crawling from '../crawling'
describe('Crawling', () => {
it('should render crawl progress', () => {
render(<Crawling crawledNum={5} totalNum={10} />)
expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render total page scraped label', () => {
render(<Crawling crawledNum={0} totalNum={0} />)
expect(screen.getByText(/stepOne\.website\.totalPageScraped/)).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<Crawling crawledNum={1} totalNum={5} className="custom" />)
expect(container.querySelector('.custom')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ErrorMessage from '../error-message'
describe('ErrorMessage', () => {
it('should render title', () => {
render(<ErrorMessage title="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should render error message when provided', () => {
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
const { container } = render(<ErrorMessage title="Error" />)
const textElements = container.querySelectorAll('.system-xs-regular')
expect(textElements).toHaveLength(0)
})
it('should apply custom className', () => {
const { container } = render(<ErrorMessage title="Error" className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@@ -1,15 +1,11 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import CheckboxWithLabel from './checkbox-with-label'
import CrawledResult from './crawled-result'
import CrawledResultItem from './crawled-result-item'
import Crawling from './crawling'
import ErrorMessage from './error-message'
// ==========================================
// Test Data Builders
// ==========================================
import CheckboxWithLabel from '../checkbox-with-label'
import CrawledResult from '../crawled-result'
import CrawledResultItem from '../crawled-result-item'
import Crawling from '../crawling'
import ErrorMessage from '../error-message'
const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItemType>): CrawlResultItemType => ({
source_url: 'https://example.com/page1',
@@ -27,9 +23,7 @@ const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => {
}))
}
// ==========================================
// CheckboxWithLabel Tests
// ==========================================
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
@@ -43,15 +37,12 @@ describe('CheckboxWithLabel', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} />)
// Assert
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render checkbox in unchecked state', () => {
// Arrange & Act
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} />)
// Assert - Custom checkbox component uses div with data-testid
@@ -61,7 +52,6 @@ describe('CheckboxWithLabel', () => {
})
it('should render checkbox in checked state', () => {
// Arrange & Act
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} />)
// Assert - Checked state has check icon
@@ -70,7 +60,6 @@ describe('CheckboxWithLabel', () => {
})
it('should render tooltip when provided', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />)
// Assert - Tooltip trigger should be present
@@ -79,10 +68,8 @@ describe('CheckboxWithLabel', () => {
})
it('should not render tooltip when not provided', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} />)
// Assert
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
expect(tooltipTrigger).not.toBeInTheDocument()
})
@@ -90,21 +77,17 @@ describe('CheckboxWithLabel', () => {
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<CheckboxWithLabel {...defaultProps} className="custom-class" />,
)
// Assert
const label = container.querySelector('label')
expect(label).toHaveClass('custom-class')
})
it('should apply custom labelClassName', () => {
// Arrange & Act
render(<CheckboxWithLabel {...defaultProps} labelClassName="custom-label-class" />)
// Assert
const labelText = screen.getByText('Test Label')
expect(labelText).toHaveClass('custom-label-class')
})
@@ -112,33 +95,26 @@ describe('CheckboxWithLabel', () => {
describe('User Interactions', () => {
it('should call onChange with true when clicking unchecked checkbox', () => {
// Arrange
const mockOnChange = vi.fn()
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} onChange={mockOnChange} />)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnChange).toHaveBeenCalledWith(true)
})
it('should call onChange with false when clicking checked checkbox', () => {
// Arrange
const mockOnChange = vi.fn()
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} onChange={mockOnChange} />)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnChange).toHaveBeenCalledWith(false)
})
it('should not trigger onChange when clicking label text due to custom checkbox', () => {
// Arrange
const mockOnChange = vi.fn()
render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />)
@@ -152,9 +128,7 @@ describe('CheckboxWithLabel', () => {
})
})
// ==========================================
// CrawledResultItem Tests
// ==========================================
describe('CrawledResultItem', () => {
const defaultProps = {
payload: createMockCrawlResultItem(),
@@ -171,16 +145,13 @@ describe('CrawledResultItem', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<CrawledResultItem {...defaultProps} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page1')).toBeInTheDocument()
})
it('should render checkbox when isMultipleChoice is true', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={true} />)
// Assert - Custom checkbox uses data-testid
@@ -189,7 +160,6 @@ describe('CrawledResultItem', () => {
})
it('should render radio when isMultipleChoice is false', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
// Assert - Radio component has size-4 rounded-full classes
@@ -198,7 +168,6 @@ describe('CrawledResultItem', () => {
})
it('should render checkbox as checked when isChecked is true', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isChecked={true} />)
// Assert - Checked state shows check icon
@@ -207,35 +176,27 @@ describe('CrawledResultItem', () => {
})
it('should render preview button when showPreview is true', () => {
// Arrange & Act
render(<CrawledResultItem {...defaultProps} showPreview={true} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not render preview button when showPreview is false', () => {
// Arrange & Act
render(<CrawledResultItem {...defaultProps} showPreview={false} />)
// Assert
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should apply active background when isPreview is true', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isPreview={true} />)
// Assert
const item = container.firstChild
expect(item).toHaveClass('bg-state-base-active')
})
it('should apply hover styles when isPreview is false', () => {
// Arrange & Act
const { container } = render(<CrawledResultItem {...defaultProps} isPreview={false} />)
// Assert
const item = container.firstChild
expect(item).toHaveClass('group')
expect(item).toHaveClass('hover:bg-state-base-hover')
@@ -244,35 +205,26 @@ describe('CrawledResultItem', () => {
describe('Props', () => {
it('should display payload title', () => {
// Arrange
const payload = createMockCrawlResultItem({ title: 'Custom Title' })
// Act
render(<CrawledResultItem {...defaultProps} payload={payload} />)
// Assert
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should display payload source_url', () => {
// Arrange
const payload = createMockCrawlResultItem({ source_url: 'https://custom.url/path' })
// Act
render(<CrawledResultItem {...defaultProps} payload={payload} />)
// Assert
expect(screen.getByText('https://custom.url/path')).toBeInTheDocument()
})
it('should set title attribute for truncation tooltip', () => {
// Arrange
const payload = createMockCrawlResultItem({ title: 'Very Long Title' })
// Act
render(<CrawledResultItem {...defaultProps} payload={payload} />)
// Assert
const titleElement = screen.getByText('Very Long Title')
expect(titleElement).toHaveAttribute('title', 'Very Long Title')
})
@@ -280,7 +232,6 @@ describe('CrawledResultItem', () => {
describe('User Interactions', () => {
it('should call onCheckChange with true when clicking unchecked checkbox', () => {
// Arrange
const mockOnCheckChange = vi.fn()
const { container } = render(
<CrawledResultItem
@@ -290,16 +241,13 @@ describe('CrawledResultItem', () => {
/>,
)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
})
it('should call onCheckChange with false when clicking checked checkbox', () => {
// Arrange
const mockOnCheckChange = vi.fn()
const { container } = render(
<CrawledResultItem
@@ -309,28 +257,22 @@ describe('CrawledResultItem', () => {
/>,
)
// Act
const checkbox = container.querySelector('[data-testid^="checkbox"]')!
fireEvent.click(checkbox)
// Assert
expect(mockOnCheckChange).toHaveBeenCalledWith(false)
})
it('should call onPreview when clicking preview button', () => {
// Arrange
const mockOnPreview = vi.fn()
render(<CrawledResultItem {...defaultProps} onPreview={mockOnPreview} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnPreview).toHaveBeenCalled()
})
it('should toggle radio state when isMultipleChoice is false', () => {
// Arrange
const mockOnCheckChange = vi.fn()
const { container } = render(
<CrawledResultItem
@@ -345,15 +287,12 @@ describe('CrawledResultItem', () => {
const radio = container.querySelector('.size-4.rounded-full')!
fireEvent.click(radio)
// Assert
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
})
})
})
// ==========================================
// CrawledResult Tests
// ==========================================
describe('CrawledResult', () => {
const defaultProps = {
list: createMockCrawlResultItems(3),
@@ -368,7 +307,6 @@ describe('CrawledResult', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} />)
// Assert - Check for time info which contains total count
@@ -376,17 +314,14 @@ describe('CrawledResult', () => {
})
it('should render all list items', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} />)
// Assert
expect(screen.getByText('Page 1')).toBeInTheDocument()
expect(screen.getByText('Page 2')).toBeInTheDocument()
expect(screen.getByText('Page 3')).toBeInTheDocument()
})
it('should display scrape time info', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} usedTime={2.5} />)
// Assert - Check for the time display
@@ -394,7 +329,6 @@ describe('CrawledResult', () => {
})
it('should render select all checkbox when isMultipleChoice is true', () => {
// Arrange & Act
const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={true} />)
// Assert - Multiple custom checkboxes (select all + items)
@@ -403,7 +337,6 @@ describe('CrawledResult', () => {
})
it('should not render select all checkbox when isMultipleChoice is false', () => {
// Arrange & Act
const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={false} />)
// Assert - No select all checkbox, only radio buttons for items
@@ -415,38 +348,30 @@ describe('CrawledResult', () => {
})
it('should show "Select All" when not all items are checked', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} checkedList={[]} />)
// Assert
expect(screen.getByText(/selectAll|Select All/i)).toBeInTheDocument()
})
it('should show "Reset All" when all items are checked', () => {
// Arrange
const allChecked = createMockCrawlResultItems(3)
// Act
render(<CrawledResult {...defaultProps} checkedList={allChecked} />)
// Assert
expect(screen.getByText(/resetAll|Reset All/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<CrawledResult {...defaultProps} className="custom-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
it('should highlight item at previewIndex', () => {
// Arrange & Act
const { container } = render(
<CrawledResult {...defaultProps} previewIndex={1} />,
)
@@ -457,7 +382,6 @@ describe('CrawledResult', () => {
})
it('should pass showPreview to items', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} showPreview={true} />)
// Assert - Preview buttons should be visible
@@ -466,17 +390,14 @@ describe('CrawledResult', () => {
})
it('should not show preview buttons when showPreview is false', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} showPreview={false} />)
// Assert
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSelectedChange with all items when clicking select all', () => {
// Arrange
const mockOnSelectedChange = vi.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
@@ -492,12 +413,10 @@ describe('CrawledResult', () => {
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[0])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith(list)
})
it('should call onSelectedChange with empty array when clicking reset all', () => {
// Arrange
const mockOnSelectedChange = vi.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
@@ -509,16 +428,13 @@ describe('CrawledResult', () => {
/>,
)
// Act
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[0])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([])
})
it('should add item to checkedList when checking unchecked item', () => {
// Arrange
const mockOnSelectedChange = vi.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
@@ -534,12 +450,10 @@ describe('CrawledResult', () => {
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[2])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]])
})
it('should remove item from checkedList when unchecking checked item', () => {
// Arrange
const mockOnSelectedChange = vi.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
@@ -555,12 +469,10 @@ describe('CrawledResult', () => {
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[1])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
})
it('should replace selection when checking in single choice mode', () => {
// Arrange
const mockOnSelectedChange = vi.fn()
const list = createMockCrawlResultItems(3)
const { container } = render(
@@ -582,7 +494,6 @@ describe('CrawledResult', () => {
})
it('should call onPreview with item and index when clicking preview', () => {
// Arrange
const mockOnPreview = vi.fn()
const list = createMockCrawlResultItems(3)
render(
@@ -594,11 +505,9 @@ describe('CrawledResult', () => {
/>,
)
// Act
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[1]) // Second item's preview button
// Assert
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
})
@@ -625,7 +534,6 @@ describe('CrawledResult', () => {
describe('Edge Cases', () => {
it('should handle empty list', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} list={[]} usedTime={0.5} />)
// Assert - Should show time info with 0 count
@@ -633,29 +541,22 @@ describe('CrawledResult', () => {
})
it('should handle single item list', () => {
// Arrange
const singleItem = [createMockCrawlResultItem()]
// Act
render(<CrawledResult {...defaultProps} list={singleItem} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should format usedTime to one decimal place', () => {
// Arrange & Act
render(<CrawledResult {...defaultProps} usedTime={1.567} />)
// Assert
expect(screen.getByText(/1.6/)).toBeInTheDocument()
})
})
})
// ==========================================
// Crawling Tests
// ==========================================
describe('Crawling', () => {
const defaultProps = {
crawledNum: 5,
@@ -668,23 +569,18 @@ describe('Crawling', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<Crawling {...defaultProps} />)
// Assert
expect(screen.getByText(/5\/10/)).toBeInTheDocument()
})
it('should display crawled count and total', () => {
// Arrange & Act
render(<Crawling crawledNum={3} totalNum={15} />)
// Assert
expect(screen.getByText(/3\/15/)).toBeInTheDocument()
})
it('should render skeleton items', () => {
// Arrange & Act
const { container } = render(<Crawling {...defaultProps} />)
// Assert - Should have 3 skeleton items
@@ -693,10 +589,8 @@ describe('Crawling', () => {
})
it('should render header skeleton block', () => {
// Arrange & Act
const { container } = render(<Crawling {...defaultProps} />)
// Assert
const headerBlocks = container.querySelectorAll('.px-4.py-2 .bg-text-quaternary')
expect(headerBlocks.length).toBeGreaterThan(0)
})
@@ -704,35 +598,28 @@ describe('Crawling', () => {
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<Crawling {...defaultProps} className="custom-crawling-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-crawling-class')
})
it('should handle zero values', () => {
// Arrange & Act
render(<Crawling crawledNum={0} totalNum={0} />)
// Assert
expect(screen.getByText(/0\/0/)).toBeInTheDocument()
})
it('should handle large numbers', () => {
// Arrange & Act
render(<Crawling crawledNum={999} totalNum={1000} />)
// Assert
expect(screen.getByText(/999\/1000/)).toBeInTheDocument()
})
})
describe('Skeleton Structure', () => {
it('should render blocks with correct width classes', () => {
// Arrange & Act
const { container } = render(<Crawling {...defaultProps} />)
// Assert - Check for various width classes
@@ -743,9 +630,7 @@ describe('Crawling', () => {
})
})
// ==========================================
// ErrorMessage Tests
// ==========================================
describe('ErrorMessage', () => {
const defaultProps = {
title: 'Error Title',
@@ -757,41 +642,32 @@ describe('ErrorMessage', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} />)
// Assert
expect(screen.getByText('Error Title')).toBeInTheDocument()
})
it('should render error icon', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('text-text-destructive')
})
it('should render title', () => {
// Arrange & Act
render(<ErrorMessage title="Custom Error Title" />)
// Assert
expect(screen.getByText('Custom Error Title')).toBeInTheDocument()
})
it('should render error message when provided', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} errorMsg="Detailed error description" />)
// Assert
expect(screen.getByText('Detailed error description')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} />)
// Assert - Should only have title, not error message container
@@ -802,17 +678,14 @@ describe('ErrorMessage', () => {
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<ErrorMessage {...defaultProps} className="custom-error-class" />,
)
// Assert
expect(container.firstChild).toHaveClass('custom-error-class')
})
it('should render with empty errorMsg', () => {
// Arrange & Act
render(<ErrorMessage {...defaultProps} errorMsg="" />)
// Assert - Empty string should not render message div
@@ -820,64 +693,47 @@ describe('ErrorMessage', () => {
})
it('should handle long title text', () => {
// Arrange
const longTitle = 'This is a very long error title that might wrap to multiple lines'
// Act
render(<ErrorMessage title={longTitle} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle long error message', () => {
// Arrange
const longErrorMsg = 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.'
// Act
render(<ErrorMessage {...defaultProps} errorMsg={longErrorMsg} />)
// Assert
expect(screen.getByText(longErrorMsg)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have error background styling', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('bg-toast-error-bg')
})
it('should have border styling', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('border-components-panel-border')
})
it('should have rounded corners', () => {
// Arrange & Act
const { container } = render(<ErrorMessage {...defaultProps} />)
// Assert
expect(container.firstChild).toHaveClass('rounded-xl')
})
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Base Components Integration', () => {
it('should render CrawledResult with CrawledResultItem children', () => {
// Arrange
const list = createMockCrawlResultItems(2)
// Act
render(
<CrawledResult
list={list}
@@ -893,10 +749,8 @@ describe('Base Components Integration', () => {
})
it('should render CrawledResult with CheckboxWithLabel for select all', () => {
// Arrange
const list = createMockCrawlResultItems(2)
// Act
const { container } = render(
<CrawledResult
list={list}
@@ -913,7 +767,6 @@ describe('Base Components Integration', () => {
})
it('should allow selecting and previewing items', () => {
// Arrange
const list = createMockCrawlResultItems(3)
const mockOnSelectedChange = vi.fn()
const mockOnPreview = vi.fn()
@@ -933,14 +786,12 @@ describe('Base Components Integration', () => {
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
fireEvent.click(checkboxes[1])
// Assert
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]])
// Act - Preview second item
const previewButtons = screen.getAllByRole('button')
fireEvent.click(previewButtons[1])
// Assert
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
})
})

View File

@@ -6,13 +6,7 @@ import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/ty
import Toast from '@/app/components/base/toast'
import { CrawlStep } from '@/models/datasets'
import { PipelineInputVarType } from '@/models/pipeline'
import Options from './index'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/vitest.setup.ts
import Options from '../index'
// Mock useInitialData and useConfigurations hooks
const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({
@@ -28,15 +22,16 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({
// Mock BaseField
const mockBaseField = vi.fn()
vi.mock('@/app/components/base/form/form-scenarios/base/field', () => {
const MockBaseFieldFactory = (props: any) => {
const MockBaseFieldFactory = (props: Record<string, unknown>) => {
mockBaseField(props)
const MockField = ({ form }: { form: any }) => (
<div data-testid={`field-${props.config?.variable || 'unknown'}`}>
<span data-testid={`field-label-${props.config?.variable}`}>{props.config?.label}</span>
const config = props.config as { variable?: string, label?: string } | undefined
const MockField = ({ form }: { form: { getFieldValue?: (field: string) => string, setFieldValue?: (field: string, value: string) => void } }) => (
<div data-testid={`field-${config?.variable || 'unknown'}`}>
<span data-testid={`field-label-${config?.variable}`}>{config?.label}</span>
<input
data-testid={`field-input-${props.config?.variable}`}
value={form.getFieldValue?.(props.config?.variable) || ''}
onChange={e => form.setFieldValue?.(props.config?.variable, e.target.value)}
data-testid={`field-input-${config?.variable}`}
value={form.getFieldValue?.(config?.variable || '') || ''}
onChange={e => form.setFieldValue?.(config?.variable || '', e.target.value)}
/>
</div>
)
@@ -47,9 +42,9 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => {
// Mock useAppForm
const mockHandleSubmit = vi.fn()
const mockFormValues: Record<string, any> = {}
const mockFormValues: Record<string, unknown> = {}
vi.mock('@/app/components/base/form', () => ({
useAppForm: (options: any) => {
useAppForm: (options: { validators?: { onSubmit?: (arg: { value: Record<string, unknown> }) => unknown }, onSubmit?: (arg: { value: Record<string, unknown> }) => void }) => {
const formOptions = options
return {
handleSubmit: () => {
@@ -60,17 +55,13 @@ vi.mock('@/app/components/base/form', () => ({
}
},
getFieldValue: (field: string) => mockFormValues[field],
setFieldValue: (field: string, value: any) => {
setFieldValue: (field: string, value: unknown) => {
mockFormValues[field] = value
},
}
},
}))
// ==========================================
// Test Data Builders
// ==========================================
const createMockVariable = (overrides?: Partial<RAGPipelineVariables[0]>): RAGPipelineVariables[0] => ({
belong_to_node_id: 'node-1',
type: PipelineInputVarType.textInput,
@@ -91,7 +82,18 @@ const createMockVariables = (count = 1): RAGPipelineVariables => {
}))
}
const createMockConfiguration = (overrides?: Partial<any>): any => ({
type MockConfiguration = {
type: BaseFieldType
variable: string
label: string
required: boolean
maxLength: number
options: unknown[]
showConditions: unknown[]
placeholder: string
}
const createMockConfiguration = (overrides?: Partial<MockConfiguration>): MockConfiguration => ({
type: BaseFieldType.textInput,
variable: 'test_variable',
label: 'Test Label',
@@ -113,9 +115,6 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps =>
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('Options', () => {
let toastNotifySpy: MockInstance
@@ -137,46 +136,33 @@ describe('Options', () => {
toastNotifySpy.mockRestore()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render options header with toggle text', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByText(/options/i)).toBeInTheDocument()
})
it('should render Run button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/run/i)).toBeInTheDocument()
})
it('should render form fields when not folded', () => {
// Arrange
const configurations = [
createMockConfiguration({ variable: 'url', label: 'URL' }),
createMockConfiguration({ variable: 'depth', label: 'Depth' }),
@@ -184,19 +170,15 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue(configurations)
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByTestId('field-url')).toBeInTheDocument()
expect(screen.getByTestId('field-depth')).toBeInTheDocument()
})
it('should render arrow icon in correct orientation when expanded', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert - Arrow should not have -rotate-90 class when expanded
@@ -206,37 +188,27 @@ describe('Options', () => {
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('variables prop', () => {
it('should pass variables to useInitialData hook', () => {
// Arrange
const variables = createMockVariables(3)
const props = createDefaultProps({ variables })
// Act
render(<Options {...props} />)
// Assert
expect(mockUseInitialData).toHaveBeenCalledWith(variables)
})
it('should pass variables to useConfigurations hook', () => {
// Arrange
const variables = createMockVariables(2)
const props = createDefaultProps({ variables })
// Act
render(<Options {...props} />)
// Assert
expect(mockUseConfigurations).toHaveBeenCalledWith(variables)
})
it('should render correct number of fields based on configurations', () => {
// Arrange
const configurations = [
createMockConfiguration({ variable: 'field_1', label: 'Field 1' }),
createMockConfiguration({ variable: 'field_2', label: 'Field 2' }),
@@ -245,24 +217,19 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue(configurations)
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByTestId('field-field_1')).toBeInTheDocument()
expect(screen.getByTestId('field-field_2')).toBeInTheDocument()
expect(screen.getByTestId('field-field_3')).toBeInTheDocument()
})
it('should handle empty variables array', () => {
// Arrange
mockUseConfigurations.mockReturnValue([])
const props = createDefaultProps({ variables: [] })
// Act
const { container } = render(<Options {...props} />)
// Assert
expect(container.querySelector('form')).toBeInTheDocument()
expect(screen.queryByTestId(/field-/)).not.toBeInTheDocument()
})
@@ -270,54 +237,40 @@ describe('Options', () => {
describe('step prop', () => {
it('should show "Run" text when step is init', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.init })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByText(/run/i)).toBeInTheDocument()
})
it('should show "Running" text when step is running', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.running })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByText(/running/i)).toBeInTheDocument()
})
it('should disable button when step is running', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.running })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable button when step is finished', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.finished, runDisabled: false })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should show loading state on button when step is running', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.running })
// Act
render(<Options {...props} />)
// Assert - Button should have loading prop which disables it
@@ -328,47 +281,35 @@ describe('Options', () => {
describe('runDisabled prop', () => {
it('should disable button when runDisabled is true', () => {
// Arrange
const props = createDefaultProps({ runDisabled: true })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable button when runDisabled is false and step is not running', () => {
// Arrange
const props = createDefaultProps({ runDisabled: false, step: CrawlStep.init })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should disable button when both runDisabled is true and step is running', () => {
// Arrange
const props = createDefaultProps({ runDisabled: true, step: CrawlStep.running })
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should default runDisabled to undefined (falsy)', () => {
// Arrange
const props = createDefaultProps()
delete (props as any).runDisabled
delete (props as Partial<OptionsProps>).runDisabled
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
@@ -385,16 +326,13 @@ describe('Options', () => {
const mockOnSubmit = vi.fn()
const props = createDefaultProps({ onSubmit: mockOnSubmit })
// Act
render(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).toHaveBeenCalled()
})
it('should not call onSubmit when validation fails', () => {
// Arrange
const mockOnSubmit = vi.fn()
// Create a required field configuration
const requiredConfig = createMockConfiguration({
@@ -407,11 +345,9 @@ describe('Options', () => {
// mockFormValues is empty, so required field validation will fail
const props = createDefaultProps({ onSubmit: mockOnSubmit })
// Act
render(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).not.toHaveBeenCalled()
})
@@ -427,22 +363,17 @@ describe('Options', () => {
const mockOnSubmit = vi.fn()
const props = createDefaultProps({ onSubmit: mockOnSubmit })
// Act
render(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 })
})
})
})
// ==========================================
// Side Effects and Cleanup (useEffect)
// ==========================================
describe('Side Effects and Cleanup', () => {
it('should expand options when step changes to init', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.finished })
const { rerender, container } = render(<Options {...props} />)
@@ -456,7 +387,6 @@ describe('Options', () => {
})
it('should collapse options when step changes to running', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.init })
const { rerender, container } = render(<Options {...props} />)
@@ -473,7 +403,6 @@ describe('Options', () => {
})
it('should collapse options when step changes to finished', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.init })
const { rerender, container } = render(<Options {...props} />)
@@ -487,7 +416,6 @@ describe('Options', () => {
})
it('should respond to step transitions from init -> running -> finished', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.init })
const { rerender, container } = render(<Options {...props} />)
@@ -512,7 +440,6 @@ describe('Options', () => {
})
it('should expand when step transitions from finished to init', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.finished })
const { rerender } = render(<Options {...props} />)
@@ -527,12 +454,9 @@ describe('Options', () => {
})
})
// ==========================================
// Memoization Logic and Dependencies
// ==========================================
describe('Memoization Logic and Dependencies', () => {
it('should regenerate schema when configurations change', () => {
// Arrange
const config1 = [createMockConfiguration({ variable: 'url' })]
const config2 = [createMockConfiguration({ variable: 'depth' })]
mockUseConfigurations.mockReturnValue(config1)
@@ -551,10 +475,8 @@ describe('Options', () => {
})
it('should compute isRunning correctly for init step', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.init })
// Act
render(<Options {...props} />)
// Assert - Button should not be in loading state
@@ -564,10 +486,8 @@ describe('Options', () => {
})
it('should compute isRunning correctly for running step', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.running })
// Act
render(<Options {...props} />)
// Assert - Button should be in loading state
@@ -577,10 +497,8 @@ describe('Options', () => {
})
it('should compute isRunning correctly for finished step', () => {
// Arrange
const props = createDefaultProps({ step: CrawlStep.finished })
// Act
render(<Options {...props} />)
// Assert - Button should not be in loading state
@@ -606,12 +524,9 @@ describe('Options', () => {
})
})
// ==========================================
// User Interactions and Event Handlers
// ==========================================
describe('User Interactions and Event Handlers', () => {
it('should toggle fold state when header is clicked', () => {
// Arrange
const props = createDefaultProps()
render(<Options {...props} />)
@@ -632,11 +547,9 @@ describe('Options', () => {
})
it('should prevent default and stop propagation on form submit', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<Options {...props} />)
// Act
const form = container.querySelector('form')!
const mockPreventDefault = vi.fn()
const mockStopPropagation = vi.fn()
@@ -662,15 +575,12 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).toHaveBeenCalled()
})
it('should not trigger submit when button is disabled', () => {
// Arrange
const mockOnSubmit = vi.fn()
const props = createDefaultProps({ onSubmit: mockOnSubmit, runDisabled: true })
render(<Options {...props} />)
@@ -678,12 +588,10 @@ describe('Options', () => {
// Act - Try to click disabled button
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it('should maintain fold state after form submission', () => {
// Arrange
const props = createDefaultProps()
render(<Options {...props} />)
@@ -698,7 +606,6 @@ describe('Options', () => {
})
it('should allow clicking on arrow icon container to toggle', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<Options {...props} />)
@@ -714,9 +621,6 @@ describe('Options', () => {
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle validation error and show toast', () => {
// Arrange - Create required field that will fail validation when empty
@@ -731,7 +635,6 @@ describe('Options', () => {
const props = createDefaultProps()
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called with error message
@@ -754,7 +657,6 @@ describe('Options', () => {
const props = createDefaultProps()
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - Toast message should contain field path
@@ -767,11 +669,9 @@ describe('Options', () => {
})
it('should handle empty variables gracefully', () => {
// Arrange
mockUseConfigurations.mockReturnValue([])
const props = createDefaultProps({ variables: [] })
// Act
const { container } = render(<Options {...props} />)
// Assert - Should render without errors
@@ -780,29 +680,23 @@ describe('Options', () => {
})
it('should handle single variable configuration', () => {
// Arrange
const singleConfig = [createMockConfiguration({ variable: 'only_field' })]
mockUseConfigurations.mockReturnValue(singleConfig)
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
expect(screen.getByTestId('field-only_field')).toBeInTheDocument()
})
it('should handle many configurations', () => {
// Arrange
const manyConfigs = Array.from({ length: 10 }, (_, i) =>
createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` }))
mockUseConfigurations.mockReturnValue(manyConfigs)
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
for (let i = 0; i < 10; i++)
expect(screen.getByTestId(`field-field_${i}`)).toBeInTheDocument()
})
@@ -817,7 +711,6 @@ describe('Options', () => {
const props = createDefaultProps()
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called once (only first error)
@@ -830,7 +723,6 @@ describe('Options', () => {
})
it('should handle validation pass when all required fields have values', () => {
// Arrange
const requiredConfig = createMockConfiguration({
variable: 'url',
label: 'URL',
@@ -843,7 +735,6 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert - No toast error, onSubmit called
@@ -852,17 +743,15 @@ describe('Options', () => {
})
it('should handle undefined variables gracefully', () => {
// Arrange
mockUseInitialData.mockReturnValue({})
mockUseConfigurations.mockReturnValue([])
const props = createDefaultProps({ variables: undefined as any })
const props = createDefaultProps({ variables: undefined as unknown as RAGPipelineVariables })
// Act & Assert - Should not throw
expect(() => render(<Options {...props} />)).not.toThrow()
})
it('should handle rapid fold/unfold toggling', () => {
// Arrange
const props = createDefaultProps()
render(<Options {...props} />)
@@ -876,9 +765,7 @@ describe('Options', () => {
})
})
// ==========================================
// All Prop Variations
// ==========================================
describe('Prop Variations', () => {
it.each([
[{ step: CrawlStep.init, runDisabled: false }, false, 'run'],
@@ -888,13 +775,10 @@ describe('Options', () => {
[{ step: CrawlStep.finished, runDisabled: false }, false, 'run'],
[{ step: CrawlStep.finished, runDisabled: true }, true, 'run'],
] as const)('should render correctly with step=%s, runDisabled=%s', (propVariation, expectedDisabled, expectedText) => {
// Arrange
const props = createDefaultProps(propVariation)
// Act
render(<Options {...props} />)
// Assert
const button = screen.getByRole('button')
if (expectedDisabled)
expect(button).toBeDisabled()
@@ -915,7 +799,6 @@ describe('Options', () => {
})
it('should handle variables with different types', () => {
// Arrange
const variables: RAGPipelineVariables = [
createMockVariable({ type: PipelineInputVarType.textInput, variable: 'text_field' }),
createMockVariable({ type: PipelineInputVarType.paragraph, variable: 'paragraph_field' }),
@@ -927,19 +810,15 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue(configurations)
const props = createDefaultProps({ variables })
// Act
render(<Options {...props} />)
// Assert
variables.forEach((v) => {
expect(screen.getByTestId(`field-${v.variable}`)).toBeInTheDocument()
})
})
})
// ==========================================
// Form Validation
// ==========================================
describe('Form Validation', () => {
it('should pass validation with valid data', () => {
// Arrange - Use non-required field so empty value passes
@@ -953,10 +832,8 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).toHaveBeenCalled()
expect(toastNotifySpy).not.toHaveBeenCalled()
})
@@ -974,10 +851,8 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnSubmit).not.toHaveBeenCalled()
expect(toastNotifySpy).toHaveBeenCalled()
})
@@ -994,10 +869,8 @@ describe('Options', () => {
const props = createDefaultProps()
render(<Options {...props} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(toastNotifySpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
@@ -1007,99 +880,75 @@ describe('Options', () => {
})
})
// ==========================================
// Styling Tests
// ==========================================
describe('Styling', () => {
it('should apply correct container classes to form', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert
const form = container.querySelector('form')
expect(form).toHaveClass('w-full')
})
it('should apply cursor-pointer class to toggle container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert
const toggleContainer = container.querySelector('.cursor-pointer')
expect(toggleContainer).toBeInTheDocument()
})
it('should apply select-none class to prevent text selection on toggle', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert
const toggleContainer = container.querySelector('.select-none')
expect(toggleContainer).toBeInTheDocument()
})
it('should apply rotate class to arrow icon when folded', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<Options {...props} />)
// Act - Fold the options
fireEvent.click(screen.getByText(/options/i))
// Assert
const arrowIcon = container.querySelector('svg')
expect(arrowIcon).toHaveClass('-rotate-90')
})
it('should not apply rotate class to arrow icon when expanded', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert
const arrowIcon = container.querySelector('svg')
expect(arrowIcon).not.toHaveClass('-rotate-90')
})
it('should apply border class to fields container when expanded', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Options {...props} />)
// Assert
const fieldsContainer = container.querySelector('.border-t')
expect(fieldsContainer).toBeInTheDocument()
})
})
// ==========================================
// BaseField Integration
// ==========================================
describe('BaseField Integration', () => {
it('should pass correct props to BaseField factory', () => {
// Arrange
const config = createMockConfiguration({ variable: 'test_var', label: 'Test Label' })
mockUseConfigurations.mockReturnValue([config])
mockUseInitialData.mockReturnValue({ test_var: 'default_value' })
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert
expect(mockBaseField).toHaveBeenCalledWith(
expect.objectContaining({
initialData: { test_var: 'default_value' },
@@ -1109,7 +958,6 @@ describe('Options', () => {
})
it('should render unique key for each field', () => {
// Arrange
const configurations = [
createMockConfiguration({ variable: 'field_a' }),
createMockConfiguration({ variable: 'field_b' }),
@@ -1118,7 +966,6 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue(configurations)
const props = createDefaultProps()
// Act
render(<Options {...props} />)
// Assert - All fields should be rendered (React would warn if keys aren't unique)