mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 09:17:19 -05:00
test: Adding missing tests or correcting existing tests (#29937)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,659 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import Header from './header'
|
||||||
|
import type { DataSourceCredential } from '@/types/pipeline'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
jest.mock('@/app/components/plugins/plugin-auth', () => ({
|
||||||
|
CredentialTypeEnum: {
|
||||||
|
OAUTH2: 'oauth2',
|
||||||
|
API_KEY: 'api_key',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock portal-to-follow-elem - required for CredentialSelector
|
||||||
|
jest.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: jest.fn(),
|
||||||
|
credentials: createMockCredentials(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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 = jest.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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,622 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import Connect from './index'
|
||||||
|
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock Modules
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
|
||||||
|
|
||||||
|
// Mock useToolIcon - hook has complex dependencies (API calls, stores)
|
||||||
|
const mockUseToolIcon = jest.fn()
|
||||||
|
jest.mock('@/app/components/workflow/hooks', () => ({
|
||||||
|
useToolIcon: (data: any) => mockUseToolIcon(data),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Data Builders
|
||||||
|
// ==========================================
|
||||||
|
const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
|
||||||
|
title: 'Test Node',
|
||||||
|
plugin_id: 'plugin-123',
|
||||||
|
provider_type: 'online_drive',
|
||||||
|
provider_name: 'online-drive-provider',
|
||||||
|
datasource_name: 'online-drive-ds',
|
||||||
|
datasource_label: 'Online Drive',
|
||||||
|
datasource_parameters: {},
|
||||||
|
datasource_configurations: {},
|
||||||
|
...overrides,
|
||||||
|
} as DataSourceNodeType)
|
||||||
|
|
||||||
|
type ConnectProps = React.ComponentProps<typeof Connect>
|
||||||
|
|
||||||
|
const createDefaultProps = (overrides?: Partial<ConnectProps>): ConnectProps => ({
|
||||||
|
nodeData: createMockNodeData(),
|
||||||
|
onSetting: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Suites
|
||||||
|
// ==========================================
|
||||||
|
describe('Connect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
// Default mock return values
|
||||||
|
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
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render the BlockIcon component', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { container } = render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - BlockIcon container should exist
|
||||||
|
const iconContainer = container.querySelector('.size-12')
|
||||||
|
expect(iconContainer).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
|
||||||
|
expect(messages.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render the not connected tip message', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Should show tip translation key
|
||||||
|
expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render the connect button with correct text', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Button should have connect text
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
expect(button).toHaveTextContent('datasetCreation.stepOne.connect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render with primary button variant', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Button should be primary variant
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
expect(button).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
const iconElement = container.querySelector('svg')
|
||||||
|
expect(iconElement).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply correct container styling', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { container } = render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Container should have expected classes
|
||||||
|
const mainContainer = container.firstChild
|
||||||
|
expect(mainContainer).toHaveClass('flex', 'flex-col', 'items-start', 'gap-y-2', 'rounded-xl', 'p-6')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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)
|
||||||
|
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
|
||||||
|
expect(messages.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ title: 'Google Drive' },
|
||||||
|
{ title: 'Dropbox' },
|
||||||
|
{ title: 'OneDrive' },
|
||||||
|
{ 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
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onSetting prop', () => {
|
||||||
|
it('should call onSetting when connect button is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnSetting = jest.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 = jest.fn()
|
||||||
|
const props = createDefaultProps({ onSetting: mockOnSetting })
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
// Assert - onClick handler receives the click event from React
|
||||||
|
expect(mockOnSetting).toHaveBeenCalled()
|
||||||
|
expect(mockOnSetting.mock.calls[0]).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onSetting on each button click', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnSetting = jest.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 = jest.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 = jest.fn()
|
||||||
|
const props = createDefaultProps({ onSetting: mockOnSetting })
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
fireEvent.keyDown(button, { key: 'Enter' })
|
||||||
|
|
||||||
|
// Assert - Button should be present and interactive
|
||||||
|
expect(button).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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
|
||||||
|
expect(mockUseToolIcon).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string icon', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseToolIcon.mockReturnValue('')
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Should still render without crashing
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined icon', () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseToolIcon.mockReturnValue(undefined)
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Should still render without crashing
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
|
||||||
|
expect(messages.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
provider_type: 'online_drive',
|
||||||
|
provider_name: 'provider',
|
||||||
|
datasource_name: 'ds',
|
||||||
|
datasource_label: 'Label',
|
||||||
|
datasource_parameters: {},
|
||||||
|
datasource_configurations: {},
|
||||||
|
} 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
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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' },
|
||||||
|
{ title: 'Dropbox', plugin_id: 'dropbox' },
|
||||||
|
{ title: 'OneDrive', plugin_id: 'onedrive' },
|
||||||
|
{ 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 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ provider_type: 'online_drive' },
|
||||||
|
{ 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ datasource_label: 'Google Drive Storage' },
|
||||||
|
{ datasource_label: 'Dropbox Files' },
|
||||||
|
{ 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
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have proper text content for screen readers', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Connect {...props} />)
|
||||||
|
|
||||||
|
// Assert - Text content should be present
|
||||||
|
const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/)
|
||||||
|
expect(messages.length).toBe(2) // Both notConnected and notConnectedTip
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import Dropdown from './index'
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Data Builders
|
||||||
|
// ==========================================
|
||||||
|
type DropdownProps = React.ComponentProps<typeof Dropdown>
|
||||||
|
|
||||||
|
const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps => ({
|
||||||
|
startIndex: 0,
|
||||||
|
breadcrumbs: ['folder1', 'folder2'],
|
||||||
|
onBreadcrumbClick: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Suites
|
||||||
|
// ==========================================
|
||||||
|
describe('Dropdown', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Rendering Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render without crashing', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Assert - Trigger button should be visible
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
expect(button).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render separator after dropdown', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Assert - Separator "/" should be visible
|
||||||
|
expect(screen.getByText('/')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
expect(button).toHaveClass('items-center')
|
||||||
|
expect(button).toHaveClass('justify-center')
|
||||||
|
expect(button).toHaveClass('rounded-md')
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.queryByText('visible-folder')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render menu content when opened', async () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps({ breadcrumbs: ['test-folder1', 'test-folder2'] })
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open dropdown
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
// Assert - Menu items should be visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test-folder1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('test-folder2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Props Testing
|
||||||
|
// ==========================================
|
||||||
|
describe('Props', () => {
|
||||||
|
describe('startIndex prop', () => {
|
||||||
|
it('should pass startIndex to Menu component', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 5,
|
||||||
|
breadcrumbs: ['folder1'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open dropdown and click on item
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('folder1'))
|
||||||
|
|
||||||
|
// Assert - Should be called with startIndex (5) + item index (0) = 5
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate correct index for second item', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 3,
|
||||||
|
breadcrumbs: ['folder1', 'folder2'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open dropdown and click on second item
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('folder2'))
|
||||||
|
|
||||||
|
// Assert - Should be called with startIndex (3) + item index (1) = 4
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
expect(screen.getByText('folder-c')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
await waitFor(() => {
|
||||||
|
// The menu container should exist but be empty
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
expect(screen.getByText('folder-v2.0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
expect(screen.getByText('Папка')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onBreadcrumbClick prop', () => {
|
||||||
|
it('should call onBreadcrumbClick with correct index when item clicked', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 0,
|
||||||
|
breadcrumbs: ['folder1'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.queryByText('test-folder')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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} />)
|
||||||
|
|
||||||
|
// Act - Open and then close
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test-folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
breadcrumbs: ['test-folder'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open dropdown
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test-folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click on breadcrumb item
|
||||||
|
fireEvent.click(screen.getByText('test-folder'))
|
||||||
|
|
||||||
|
// Assert - Menu should close
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('test-folder')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Assert - Initial state (closed): should have hover:bg-state-base-hover
|
||||||
|
expect(button).toHaveClass('hover:bg-state-base-hover')
|
||||||
|
|
||||||
|
// Act - Open dropdown
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
// Assert - Open state: should have bg-state-base-hover
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button).toHaveClass('bg-state-base-hover')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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} />)
|
||||||
|
|
||||||
|
// Act & Assert - Initially closed
|
||||||
|
expect(screen.queryByText('folder')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Act - Click to open
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
// Assert - Now open
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle multiple times correctly', async () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps({ breadcrumbs: ['folder'] })
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
|
||||||
|
// Act & Assert - Toggle multiple times
|
||||||
|
// 1st click - open
|
||||||
|
fireEvent.click(button)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2nd click - close
|
||||||
|
fireEvent.click(button)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('folder')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3rd click - open again
|
||||||
|
fireEvent.click(button)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleBreadCrumbClick', () => {
|
||||||
|
it('should call onBreadcrumbClick and close menu', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
breadcrumbs: ['folder1'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open dropdown
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click on breadcrumb
|
||||||
|
fireEvent.click(screen.getByText('folder1'))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Menu should close
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('folder1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass correct index to onBreadcrumbClick for each item', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 2,
|
||||||
|
breadcrumbs: ['folder1', 'folder2', 'folder3'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open dropdown and click first item
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('folder1'))
|
||||||
|
|
||||||
|
// Assert - Index should be startIndex (2) + item index (0) = 2
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Callback Stability and Memoization Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('Callback Stability and Memoization', () => {
|
||||||
|
it('should be wrapped with React.memo', () => {
|
||||||
|
// Assert - Dropdown component should be memoized
|
||||||
|
expect(Dropdown).toHaveProperty('$$typeof', Symbol.for('react.memo'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain stable callback after rerender with same props', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
breadcrumbs: ['folder'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
const { rerender } = render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open and click
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('folder'))
|
||||||
|
|
||||||
|
// Rerender with same props and click again
|
||||||
|
rerender(<Dropdown {...props} />)
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('folder'))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update callback when onBreadcrumbClick prop changes', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick1 = jest.fn()
|
||||||
|
const mockOnBreadcrumbClick2 = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
breadcrumbs: ['folder'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick1,
|
||||||
|
})
|
||||||
|
const { rerender } = render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open and click with first callback
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('folder'))
|
||||||
|
|
||||||
|
// Rerender with different callback
|
||||||
|
rerender(<Dropdown {...createDefaultProps({
|
||||||
|
breadcrumbs: ['folder'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick2,
|
||||||
|
})} />)
|
||||||
|
|
||||||
|
// Open and click with second callback
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
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} />)
|
||||||
|
|
||||||
|
// Act - Rerender with same props
|
||||||
|
rerender(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Assert - Component should render without errors
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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')
|
||||||
|
|
||||||
|
// Act - Rapid clicks
|
||||||
|
fireEvent.click(button)
|
||||||
|
fireEvent.click(button)
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
// Assert - Should handle gracefully (open after odd number of clicks)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('folder-0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('folder-19')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle startIndex of 0', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 0,
|
||||||
|
breadcrumbs: ['folder'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
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 = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 999,
|
||||||
|
breadcrumbs: ['folder'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
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 },
|
||||||
|
{ startIndex: 1, breadcrumbs: ['a'], expectedIndex: 1 },
|
||||||
|
{ 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 = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex,
|
||||||
|
breadcrumbs,
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ breadcrumbs: [], description: 'empty array' },
|
||||||
|
{ breadcrumbs: ['single'], description: 'single item' },
|
||||||
|
{ 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'))
|
||||||
|
|
||||||
|
// Assert - Should render without errors
|
||||||
|
await waitFor(() => {
|
||||||
|
if (breadcrumbs.length > 0)
|
||||||
|
expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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()
|
||||||
|
expect(screen.getByText('Archive')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle click on any menu item', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 0,
|
||||||
|
breadcrumbs: ['first', 'second', 'third'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open and click on second item
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('second')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('second'))
|
||||||
|
|
||||||
|
// Assert - Index should be 1 (second item)
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close menu after any item click', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
breadcrumbs: ['item1', 'item2', 'item3'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
// Act - Open and click on middle item
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('item2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('item2'))
|
||||||
|
|
||||||
|
// Assert - Menu should close
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('item1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('item2')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('item3')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should correctly calculate index for each item based on startIndex', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnBreadcrumbClick = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
startIndex: 3,
|
||||||
|
breadcrumbs: ['folder-a', 'folder-b', 'folder-c'],
|
||||||
|
onBreadcrumbClick: mockOnBreadcrumbClick,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test clicking each item
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
mockOnBreadcrumbClick.mockClear()
|
||||||
|
const { unmount } = render(<Dropdown {...props} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(`folder-${String.fromCharCode(97 + i)}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText(`folder-${String.fromCharCode(97 + i)}`))
|
||||||
|
|
||||||
|
expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(3 + i)
|
||||||
|
unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,727 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import Header from './index'
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock Modules
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
|
||||||
|
|
||||||
|
// Mock store - required by Breadcrumbs component
|
||||||
|
const mockStoreState = {
|
||||||
|
hasBucket: false,
|
||||||
|
setOnlineDriveFileList: jest.fn(),
|
||||||
|
setSelectedFileIds: jest.fn(),
|
||||||
|
setBreadcrumbs: jest.fn(),
|
||||||
|
setPrefix: jest.fn(),
|
||||||
|
setBucket: jest.fn(),
|
||||||
|
breadcrumbs: [],
|
||||||
|
prefix: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockGetState = jest.fn(() => mockStoreState)
|
||||||
|
const mockDataSourceStore = { getState: mockGetState }
|
||||||
|
|
||||||
|
jest.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 => ({
|
||||||
|
breadcrumbs: [],
|
||||||
|
inputValue: '',
|
||||||
|
keywords: '',
|
||||||
|
bucket: '',
|
||||||
|
searchResultsLength: 0,
|
||||||
|
handleInputChange: jest.fn(),
|
||||||
|
handleResetKeywords: jest.fn(),
|
||||||
|
isInPipeline: false,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helper Functions
|
||||||
|
// ==========================================
|
||||||
|
const resetMockStoreState = () => {
|
||||||
|
mockStoreState.hasBucket = false
|
||||||
|
mockStoreState.setOnlineDriveFileList = jest.fn()
|
||||||
|
mockStoreState.setSelectedFileIds = jest.fn()
|
||||||
|
mockStoreState.setBreadcrumbs = jest.fn()
|
||||||
|
mockStoreState.setPrefix = jest.fn()
|
||||||
|
mockStoreState.setBucket = jest.fn()
|
||||||
|
mockStoreState.breadcrumbs = []
|
||||||
|
mockStoreState.prefix = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Suites
|
||||||
|
// ==========================================
|
||||||
|
describe('Header', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.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
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render with correct container styles', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { container } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Assert - container should have correct class names
|
||||||
|
const wrapper = container.firstChild as HTMLElement
|
||||||
|
expect(wrapper).toHaveClass('flex')
|
||||||
|
expect(wrapper).toHaveClass('items-center')
|
||||||
|
expect(wrapper).toHaveClass('gap-x-2')
|
||||||
|
expect(wrapper).toHaveClass('bg-components-panel-bg')
|
||||||
|
expect(wrapper).toHaveClass('p-1')
|
||||||
|
expect(wrapper).toHaveClass('pl-3')
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
const searchIcon = container.querySelector('svg.h-4.w-4')
|
||||||
|
expect(searchIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const inputWrapper = container.querySelector('.w-\\[200px\\]')
|
||||||
|
expect(inputWrapper).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('breadcrumbs prop', () => {
|
||||||
|
it('should render with empty breadcrumbs', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps({ breadcrumbs: [] })
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Assert - Component should render without errors
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = jest.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 = jest.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 = jest.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 = jest.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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleResetKeywords', () => {
|
||||||
|
it('should call handleResetKeywords when clear icon is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockHandleResetKeywords = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
inputValue: 'to-clear',
|
||||||
|
handleResetKeywords: mockHandleResetKeywords,
|
||||||
|
})
|
||||||
|
const { container } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Act - Find and click the clear icon container
|
||||||
|
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
|
||||||
|
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} />)
|
||||||
|
|
||||||
|
// Act & Assert - Clear icon should not be visible
|
||||||
|
const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')
|
||||||
|
expect(clearIcon).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show clear icon when inputValue is not empty', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps({ inputValue: 'some-value' })
|
||||||
|
const { container } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Act & Assert - Clear icon should be visible
|
||||||
|
const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')
|
||||||
|
expect(clearIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Component Memoization Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('Memoization', () => {
|
||||||
|
it('should be wrapped with React.memo', () => {
|
||||||
|
// Assert - Header component should be memoized
|
||||||
|
expect(Header).toHaveProperty('$$typeof', Symbol.for('react.memo'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not re-render when props are the same', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockHandleInputChange = jest.fn()
|
||||||
|
const mockHandleResetKeywords = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
handleInputChange: mockHandleInputChange,
|
||||||
|
handleResetKeywords: mockHandleResetKeywords,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act - Initial render
|
||||||
|
const { rerender } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Rerender with same props
|
||||||
|
rerender(<Header {...props} />)
|
||||||
|
|
||||||
|
// Assert - Component renders without errors
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
expect(input).toHaveValue('initial')
|
||||||
|
|
||||||
|
// Act - Rerender with different inputValue
|
||||||
|
const newProps = createDefaultProps({ inputValue: 'changed' })
|
||||||
|
rerender(<Header {...newProps} />)
|
||||||
|
|
||||||
|
// Assert - Input value should be updated
|
||||||
|
expect(input).toHaveValue('changed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-render when breadcrumbs change', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps({ breadcrumbs: [] })
|
||||||
|
const { rerender } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Act - Rerender with different breadcrumbs
|
||||||
|
const newProps = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'] })
|
||||||
|
rerender(<Header {...newProps} />)
|
||||||
|
|
||||||
|
// Assert - Component renders without errors
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-render when keywords change', () => {
|
||||||
|
// Arrange
|
||||||
|
const props = createDefaultProps({ keywords: '' })
|
||||||
|
const { rerender } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Act - Rerender with different keywords
|
||||||
|
const newProps = createDefaultProps({ keywords: 'search-term' })
|
||||||
|
rerender(<Header {...newProps} />)
|
||||||
|
|
||||||
|
// Assert - Component renders without errors
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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 = jest.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
|
||||||
|
expect(mockHandleInputChange).toHaveBeenCalledTimes(1)
|
||||||
|
const eventArg = mockHandleInputChange.mock.calls[0][0]
|
||||||
|
expect(eventArg).toHaveProperty('type', 'change')
|
||||||
|
expect(eventArg).toHaveProperty('target')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// All Prop Variations Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('Prop Variations', () => {
|
||||||
|
it.each([
|
||||||
|
{ isInPipeline: true, bucket: '' },
|
||||||
|
{ isInPipeline: true, bucket: 'my-bucket' },
|
||||||
|
{ 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ keywords: '', searchResultsLength: 0, description: 'no search' },
|
||||||
|
{ keywords: 'test', searchResultsLength: 0, description: 'search with no results' },
|
||||||
|
{ 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ breadcrumbs: [], inputValue: '', expected: 'empty state' },
|
||||||
|
{ breadcrumbs: ['root'], inputValue: 'search', expected: 'single breadcrumb with search' },
|
||||||
|
{ 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',
|
||||||
|
bucket: 'test-bucket',
|
||||||
|
searchResultsLength: 10,
|
||||||
|
isInPipeline: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Assert - Component should render successfully, meaning props are passed correctly
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass correct props to Input component', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockHandleInputChange = jest.fn()
|
||||||
|
const mockHandleResetKeywords = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
inputValue: 'test-input',
|
||||||
|
handleInputChange: mockHandleInputChange,
|
||||||
|
handleResetKeywords: mockHandleResetKeywords,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
|
||||||
|
expect(input).toHaveValue('test-input')
|
||||||
|
|
||||||
|
// Test onChange handler
|
||||||
|
fireEvent.change(input, { target: { value: 'new-value' } })
|
||||||
|
expect(mockHandleInputChange).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Callback Stability Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('Callback Stability', () => {
|
||||||
|
it('should maintain stable handleInputChange callback after rerender', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockHandleInputChange = jest.fn()
|
||||||
|
const props = createDefaultProps({ handleInputChange: mockHandleInputChange })
|
||||||
|
const { rerender } = render(<Header {...props} />)
|
||||||
|
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
|
||||||
|
|
||||||
|
// Act - Fire change event, rerender, fire again
|
||||||
|
fireEvent.change(input, { target: { value: 'first' } })
|
||||||
|
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 = jest.fn()
|
||||||
|
const props = createDefaultProps({
|
||||||
|
inputValue: 'to-clear',
|
||||||
|
handleResetKeywords: mockHandleResetKeywords,
|
||||||
|
})
|
||||||
|
const { container, rerender } = render(<Header {...props} />)
|
||||||
|
|
||||||
|
// Act - Click clear, rerender, click again
|
||||||
|
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
|
||||||
|
fireEvent.click(clearButton!)
|
||||||
|
rerender(<Header {...props} />)
|
||||||
|
fireEvent.click(clearButton!)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import FileList from './index'
|
||||||
|
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||||
|
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock Modules
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
|
||||||
|
|
||||||
|
// Mock ahooks useDebounceFn - third-party library requires mocking
|
||||||
|
const mockDebounceFnRun = jest.fn()
|
||||||
|
jest.mock('ahooks', () => ({
|
||||||
|
useDebounceFn: (fn: (...args: any[]) => void) => {
|
||||||
|
mockDebounceFnRun.mockImplementation(fn)
|
||||||
|
return { run: mockDebounceFnRun }
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock store - context provider requires mocking
|
||||||
|
const mockStoreState = {
|
||||||
|
setNextPageParameters: jest.fn(),
|
||||||
|
currentNextPageParametersRef: { current: {} },
|
||||||
|
isTruncated: { current: false },
|
||||||
|
hasBucket: false,
|
||||||
|
setOnlineDriveFileList: jest.fn(),
|
||||||
|
setSelectedFileIds: jest.fn(),
|
||||||
|
setBreadcrumbs: jest.fn(),
|
||||||
|
setPrefix: jest.fn(),
|
||||||
|
setBucket: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockGetState = jest.fn(() => mockStoreState)
|
||||||
|
const mockDataSourceStore = { getState: mockGetState }
|
||||||
|
|
||||||
|
jest.mock('../../store', () => ({
|
||||||
|
useDataSourceStore: () => mockDataSourceStore,
|
||||||
|
useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Data Builders
|
||||||
|
// ==========================================
|
||||||
|
const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({
|
||||||
|
id: 'file-1',
|
||||||
|
name: 'test-file.txt',
|
||||||
|
size: 1024,
|
||||||
|
type: OnlineDriveFileType.file,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
type FileListProps = React.ComponentProps<typeof FileList>
|
||||||
|
|
||||||
|
const createDefaultProps = (overrides?: Partial<FileListProps>): FileListProps => ({
|
||||||
|
fileList: [],
|
||||||
|
selectedFileIds: [],
|
||||||
|
breadcrumbs: [],
|
||||||
|
keywords: '',
|
||||||
|
bucket: '',
|
||||||
|
isInPipeline: false,
|
||||||
|
resetKeywords: jest.fn(),
|
||||||
|
updateKeywords: jest.fn(),
|
||||||
|
searchResultsLength: 0,
|
||||||
|
handleSelectFile: jest.fn(),
|
||||||
|
handleOpenFolder: jest.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
supportBatchUpload: true,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helper Functions
|
||||||
|
// ==========================================
|
||||||
|
const resetMockStoreState = () => {
|
||||||
|
mockStoreState.setNextPageParameters = jest.fn()
|
||||||
|
mockStoreState.currentNextPageParametersRef = { current: {} }
|
||||||
|
mockStoreState.isTruncated = { current: false }
|
||||||
|
mockStoreState.hasBucket = false
|
||||||
|
mockStoreState.setOnlineDriveFileList = jest.fn()
|
||||||
|
mockStoreState.setSelectedFileIds = jest.fn()
|
||||||
|
mockStoreState.setBreadcrumbs = jest.fn()
|
||||||
|
mockStoreState.setPrefix = jest.fn()
|
||||||
|
mockStoreState.setBucket = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Suites
|
||||||
|
// ==========================================
|
||||||
|
describe('FileList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
resetMockStoreState()
|
||||||
|
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
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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]')
|
||||||
|
expect(wrapper).toHaveClass('flex-col')
|
||||||
|
expect(wrapper).toHaveClass('overflow-hidden')
|
||||||
|
expect(wrapper).toHaveClass('rounded-xl')
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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' }),
|
||||||
|
createMockOnlineDriveFile({ id: '3', name: 'c.txt' }),
|
||||||
|
]
|
||||||
|
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
|
||||||
|
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
expect(screen.getByTestId('checkbox-file-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('check-icon-file-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('checkbox-file-2')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('check-icon-file-2')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(container.querySelector('.animation-spin')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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}"
|
||||||
|
expect(screen.getByTestId('checkbox-file-1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(container.querySelector('.rounded-full')).toBeInTheDocument()
|
||||||
|
// And checkbox should not be present
|
||||||
|
expect(screen.queryByTestId('checkbox-file-1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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 = jest.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 = jest.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 = jest.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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleResetKeywords', () => {
|
||||||
|
it('should call resetKeywords prop when clear button is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockResetKeywords = jest.fn()
|
||||||
|
const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' })
|
||||||
|
const { container } = render(<FileList {...props} />)
|
||||||
|
|
||||||
|
// Act - Click the clear icon div (it contains RiCloseCircleFill icon)
|
||||||
|
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
|
||||||
|
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')
|
||||||
|
fireEvent.change(input, { target: { value: 'some-search' } })
|
||||||
|
|
||||||
|
// Act - Find and click the clear icon
|
||||||
|
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
|
||||||
|
expect(clearButton).toBeInTheDocument()
|
||||||
|
fireEvent.click(clearButton!)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(input).toHaveValue('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleSelectFile', () => {
|
||||||
|
it('should call handleSelectFile when file item is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockHandleSelectFile = jest.fn()
|
||||||
|
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })]
|
||||||
|
const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList })
|
||||||
|
render(<FileList {...props} />)
|
||||||
|
|
||||||
|
// Act - Click on the file item
|
||||||
|
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',
|
||||||
|
type: OnlineDriveFileType.file,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleOpenFolder', () => {
|
||||||
|
it('should call handleOpenFolder when folder item is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockHandleOpenFolder = jest.fn()
|
||||||
|
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
|
||||||
|
const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList })
|
||||||
|
render(<FileList {...props} />)
|
||||||
|
|
||||||
|
// Act - Click on the folder item
|
||||||
|
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',
|
||||||
|
type: OnlineDriveFileType.folder,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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
|
||||||
|
expect(screen.getByText('file-0.txt')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('file-49.txt')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 },
|
||||||
|
{ isInPipeline: true, supportBatchUpload: false },
|
||||||
|
{ 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
|
||||||
|
expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ isLoading: true, fileCount: 0, description: 'loading state with no files' },
|
||||||
|
{ 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()
|
||||||
|
|
||||||
|
else if (!isLoading && fileCount === 0)
|
||||||
|
expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument()
|
||||||
|
|
||||||
|
else
|
||||||
|
expect(screen.getByText('file-0.txt')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ keywords: '', searchResultsLength: 0 },
|
||||||
|
{ 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
|
||||||
|
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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 = jest.fn()
|
||||||
|
const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })]
|
||||||
|
const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList })
|
||||||
|
const { rerender } = render(<FileList {...props} />)
|
||||||
|
|
||||||
|
// Act - Click once
|
||||||
|
const fileItem = screen.getByText('test.txt')
|
||||||
|
fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!)
|
||||||
|
|
||||||
|
// 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 = jest.fn()
|
||||||
|
const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })]
|
||||||
|
const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList })
|
||||||
|
const { rerender } = render(<FileList {...props} />)
|
||||||
|
|
||||||
|
// Act - Click once
|
||||||
|
const folderItem = screen.getByText('my-folder')
|
||||||
|
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
|
||||||
|
|
||||||
|
// Rerender with same props
|
||||||
|
rerender(<FileList {...props} />)
|
||||||
|
|
||||||
|
// Click again
|
||||||
|
fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockHandleOpenFolder).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,947 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import CheckboxWithLabel from './checkbox-with-label'
|
||||||
|
import CrawledResultItem from './crawled-result-item'
|
||||||
|
import CrawledResult from './crawled-result'
|
||||||
|
import Crawling from './crawling'
|
||||||
|
import ErrorMessage from './error-message'
|
||||||
|
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Test Data Builders
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItemType>): CrawlResultItemType => ({
|
||||||
|
source_url: 'https://example.com/page1',
|
||||||
|
title: 'Test Page Title',
|
||||||
|
markdown: '# Test content',
|
||||||
|
description: 'Test description',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => {
|
||||||
|
return Array.from({ length: count }, (_, i) =>
|
||||||
|
createMockCrawlResultItem({
|
||||||
|
source_url: `https://example.com/page${i + 1}`,
|
||||||
|
title: `Page ${i + 1}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CheckboxWithLabel Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('CheckboxWithLabel', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isChecked: false,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
label: 'Test Label',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const checkbox = container.querySelector('[data-testid^="checkbox"]')
|
||||||
|
expect(checkbox).toBeInTheDocument()
|
||||||
|
expect(checkbox).not.toHaveClass('bg-components-checkbox-bg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render checkbox in checked state', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} />)
|
||||||
|
|
||||||
|
// Assert - Checked state has check icon
|
||||||
|
const checkIcon = container.querySelector('[data-testid^="check-icon"]')
|
||||||
|
expect(checkIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render tooltip when provided', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />)
|
||||||
|
|
||||||
|
// Assert - Tooltip trigger should be present
|
||||||
|
const tooltipTrigger = document.querySelector('[class*="ml-0.5"]')
|
||||||
|
expect(tooltipTrigger).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('should call onChange with true when clicking unchecked checkbox', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnChange = jest.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 = jest.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 = jest.fn()
|
||||||
|
render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />)
|
||||||
|
|
||||||
|
// Act - Click on the label text element
|
||||||
|
const labelText = screen.getByText('Test Label')
|
||||||
|
fireEvent.click(labelText)
|
||||||
|
|
||||||
|
// Assert - Custom checkbox does not support native label-input click forwarding
|
||||||
|
expect(mockOnChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CrawledResultItem Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('CrawledResultItem', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
payload: createMockCrawlResultItem(),
|
||||||
|
isChecked: false,
|
||||||
|
onCheckChange: jest.fn(),
|
||||||
|
isPreview: false,
|
||||||
|
showPreview: true,
|
||||||
|
onPreview: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const checkbox = container.querySelector('[data-testid^="checkbox"]')
|
||||||
|
expect(checkbox).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const radio = container.querySelector('.size-4.rounded-full')
|
||||||
|
expect(radio).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const checkIcon = container.querySelector('[data-testid^="check-icon"]')
|
||||||
|
expect(checkIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('should call onCheckChange with true when clicking unchecked checkbox', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnCheckChange = jest.fn()
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResultItem
|
||||||
|
{...defaultProps}
|
||||||
|
isChecked={false}
|
||||||
|
onCheckChange={mockOnCheckChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 = jest.fn()
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResultItem
|
||||||
|
{...defaultProps}
|
||||||
|
isChecked={true}
|
||||||
|
onCheckChange={mockOnCheckChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 = jest.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 = jest.fn()
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResultItem
|
||||||
|
{...defaultProps}
|
||||||
|
isMultipleChoice={false}
|
||||||
|
isChecked={false}
|
||||||
|
onCheckChange={mockOnCheckChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Radio uses size-4 rounded-full classes
|
||||||
|
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),
|
||||||
|
checkedList: [] as CrawlResultItemType[],
|
||||||
|
onSelectedChange: jest.fn(),
|
||||||
|
usedTime: 1.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render without crashing', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<CrawledResult {...defaultProps} />)
|
||||||
|
|
||||||
|
// Assert - Check for time info which contains total count
|
||||||
|
expect(screen.getByText(/1.5/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.getByText(/2.5/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
|
||||||
|
expect(checkboxes.length).toBe(4) // 1 select all + 3 items
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
|
||||||
|
expect(checkboxes.length).toBe(0)
|
||||||
|
// Radio buttons have size-4 and rounded-full classes
|
||||||
|
const radios = container.querySelectorAll('.size-4.rounded-full')
|
||||||
|
expect(radios.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
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} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - Second item should have active state
|
||||||
|
const items = container.querySelectorAll('[class*="rounded-lg"][class*="cursor-pointer"]')
|
||||||
|
expect(items[1]).toHaveClass('bg-state-base-active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass showPreview to items', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<CrawledResult {...defaultProps} showPreview={true} />)
|
||||||
|
|
||||||
|
// Assert - Preview buttons should be visible
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
expect(buttons.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = jest.fn()
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
checkedList={[]}
|
||||||
|
onSelectedChange={mockOnSelectedChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Click select all checkbox (first checkbox)
|
||||||
|
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 = jest.fn()
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
checkedList={list}
|
||||||
|
onSelectedChange={mockOnSelectedChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 = jest.fn()
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
checkedList={[list[0]]}
|
||||||
|
onSelectedChange={mockOnSelectedChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Click second item checkbox (index 2, accounting for select all)
|
||||||
|
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 = jest.fn()
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
checkedList={[list[0], list[1]]}
|
||||||
|
onSelectedChange={mockOnSelectedChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Uncheck first item (index 1, after select all)
|
||||||
|
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 = jest.fn()
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
checkedList={[list[0]]}
|
||||||
|
onSelectedChange={mockOnSelectedChange}
|
||||||
|
isMultipleChoice={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Click second item radio (Radio uses size-4 rounded-full classes)
|
||||||
|
const radios = container.querySelectorAll('.size-4.rounded-full')
|
||||||
|
fireEvent.click(radios[1])
|
||||||
|
|
||||||
|
// Assert - Should only select the clicked item
|
||||||
|
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onPreview with item and index when clicking preview', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnPreview = jest.fn()
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
onPreview={mockOnPreview}
|
||||||
|
showPreview={true}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons[1]) // Second item's preview button
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not crash when clicking preview without onPreview callback', () => {
|
||||||
|
// Arrange - showPreview is true but onPreview is undefined
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
render(
|
||||||
|
<CrawledResult
|
||||||
|
{...defaultProps}
|
||||||
|
list={list}
|
||||||
|
onPreview={undefined}
|
||||||
|
showPreview={true}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Click preview button should trigger early return in handlePreview
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons[0])
|
||||||
|
|
||||||
|
// Assert - Should not throw error, component still renders
|
||||||
|
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.getByText(/0.5/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
totalNum: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const skeletonItems = container.querySelectorAll('.px-2.py-\\[5px\\]')
|
||||||
|
expect(skeletonItems.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(container.querySelector('.w-\\[35\\%\\]')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.w-\\[50\\%\\]')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.w-\\[40\\%\\]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ErrorMessage Tests
|
||||||
|
// ==========================================
|
||||||
|
describe('ErrorMessage', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
title: 'Error Title',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
const textElements = screen.getAllByText(/Error Title/)
|
||||||
|
expect(textElements.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
expect(screen.getByText('Error Title')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
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}
|
||||||
|
checkedList={[]}
|
||||||
|
onSelectedChange={jest.fn()}
|
||||||
|
usedTime={1.0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - Both items should render
|
||||||
|
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Page 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render CrawledResult with CheckboxWithLabel for select all', () => {
|
||||||
|
// Arrange
|
||||||
|
const list = createMockCrawlResultItems(2)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
list={list}
|
||||||
|
checkedList={[]}
|
||||||
|
onSelectedChange={jest.fn()}
|
||||||
|
usedTime={1.0}
|
||||||
|
isMultipleChoice={true}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - Should have select all checkbox + item checkboxes
|
||||||
|
const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]')
|
||||||
|
expect(checkboxes.length).toBe(3) // select all + 2 items
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow selecting and previewing items', () => {
|
||||||
|
// Arrange
|
||||||
|
const list = createMockCrawlResultItems(3)
|
||||||
|
const mockOnSelectedChange = jest.fn()
|
||||||
|
const mockOnPreview = jest.fn()
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<CrawledResult
|
||||||
|
list={list}
|
||||||
|
checkedList={[]}
|
||||||
|
onSelectedChange={mockOnSelectedChange}
|
||||||
|
onPreview={mockOnPreview}
|
||||||
|
showPreview={true}
|
||||||
|
usedTime={1.0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - Select first item (index 1, after select all)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user