mirror of
https://github.com/langgenius/dify.git
synced 2026-02-13 16:00:55 -05:00
test: enhance unit tests for various components including chat, datasets, and documents
- Updated tests for to ensure proper async behavior. - Added comprehensive tests for , , and components, covering rendering, user interactions, and edge cases. - Introduced new tests for , , and components, validating rendering and user interactions. - Implemented tests for status filtering and document list query state to ensure correct functionality. These changes improve test coverage and reliability across multiple components.
This commit is contained in:
@@ -162,8 +162,10 @@ describe('useEmbeddedChatbot', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
|
||||
expect(result.current.conversationList).toEqual(listData.data)
|
||||
await waitFor(() => {
|
||||
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
|
||||
expect(result.current.conversationList).toEqual(listData.data)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,111 +1,312 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { QA } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
// Mock the SelectionMod icon from base icons
|
||||
vi.mock('../base/icons/src/public/knowledge', () => ({
|
||||
SelectionMod: (props: React.ComponentProps<'svg'>) => (
|
||||
<svg data-testid="selection-mod-icon" {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ChunkLabel', () => {
|
||||
it('should render label text', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
|
||||
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render character count', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
|
||||
expect(screen.getByText('150 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator dot', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with zero character count', () => {
|
||||
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
|
||||
expect(screen.getByText('0 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with large character count', () => {
|
||||
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
|
||||
expect(screen.getByText('999999 characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChunkContainer', () => {
|
||||
it('should render label and character count', () => {
|
||||
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
|
||||
expect(screen.getByText('Container 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('200 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children content', () => {
|
||||
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Container" characterCount={100}>
|
||||
<div data-testid="child-div">
|
||||
<span>Nested content</span>
|
||||
</div>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
expect(screen.getByTestId('child-div')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty children', () => {
|
||||
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('QAPreview', () => {
|
||||
const mockQA = {
|
||||
question: 'What is the meaning of life?',
|
||||
answer: 'The meaning of life is 42.',
|
||||
// Factory for QA test data
|
||||
function createQA(overrides: Partial<QA> = {}): QA {
|
||||
return {
|
||||
question: 'What is Dify?',
|
||||
answer: 'Dify is an open-source LLM app development platform.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('should render question text', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
|
||||
// Tests for ChunkLabel - displays an icon, label, and character count
|
||||
describe('ChunkLabel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render answer text', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
|
||||
describe('Rendering', () => {
|
||||
it('should render the label text', () => {
|
||||
render(<ChunkLabel label="Chunk #1" characterCount={100} />)
|
||||
|
||||
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the character count with unit', () => {
|
||||
render(<ChunkLabel label="Chunk #1" characterCount={256} />)
|
||||
|
||||
expect(screen.getByText('256 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the SelectionMod icon', () => {
|
||||
render(<ChunkLabel label="Chunk" characterCount={10} />)
|
||||
|
||||
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a middle dot separator between label and count', () => {
|
||||
render(<ChunkLabel label="Chunk" characterCount={10} />)
|
||||
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Q label', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
describe('Props', () => {
|
||||
it('should display zero character count', () => {
|
||||
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
|
||||
|
||||
expect(screen.getByText('0 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display large character counts', () => {
|
||||
render(<ChunkLabel label="Large" characterCount={999999} />)
|
||||
|
||||
expect(screen.getByText('999999 characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render A label', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty label', () => {
|
||||
render(<ChunkLabel label="" characterCount={50} />)
|
||||
|
||||
it('should render with empty strings', () => {
|
||||
render(<QAPreview qa={{ question: '', answer: '' }} />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('50 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text', () => {
|
||||
const longQuestion = 'Q'.repeat(500)
|
||||
const longAnswer = 'A'.repeat(500)
|
||||
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
|
||||
expect(screen.getByText(longQuestion)).toBeInTheDocument()
|
||||
expect(screen.getByText(longAnswer)).toBeInTheDocument()
|
||||
})
|
||||
it('should render with special characters in label', () => {
|
||||
render(<ChunkLabel label="Chunk <#1> & 'test'" characterCount={10} />)
|
||||
|
||||
it('should render with special characters', () => {
|
||||
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
|
||||
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
|
||||
expect(screen.getByText('& special chars!')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for ChunkContainer - wraps ChunkLabel with children content area
|
||||
describe('ChunkContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render ChunkLabel with correct props', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk #1" characterCount={200}>
|
||||
Content here
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
|
||||
expect(screen.getByText('200 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in the content area', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk" characterCount={50}>
|
||||
<p>Paragraph content</p>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Paragraph content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the SelectionMod icon via ChunkLabel', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk" characterCount={10}>
|
||||
Content
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Structure', () => {
|
||||
it('should have space-y-2 on the outer container', () => {
|
||||
const { container } = render(
|
||||
<ChunkContainer label="Chunk" characterCount={10}>Content</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('space-y-2')
|
||||
})
|
||||
|
||||
it('should render children inside a styled content div', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk" characterCount={10}>
|
||||
<span>Test child</span>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
const contentDiv = screen.getByText('Test child').parentElement
|
||||
expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without children', () => {
|
||||
const { container } = render(
|
||||
<ChunkContainer label="Empty" characterCount={0} />,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toBeInTheDocument()
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Multi" characterCount={100}>
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with string children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Text" characterCount={5}>
|
||||
Plain text content
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Plain text content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for QAPreview - displays question and answer pair
|
||||
describe('QAPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the question text', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the answer text', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Q and A labels', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Structure', () => {
|
||||
it('should render Q label as a label element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const qLabel = screen.getByText('Q')
|
||||
expect(qLabel.tagName).toBe('LABEL')
|
||||
})
|
||||
|
||||
it('should render A label as a label element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const aLabel = screen.getByText('A')
|
||||
expect(aLabel.tagName).toBe('LABEL')
|
||||
})
|
||||
|
||||
it('should render question in a p element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const questionEl = screen.getByText(qa.question)
|
||||
expect(questionEl.tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should render answer in a p element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const answerEl = screen.getByText(qa.answer)
|
||||
expect(answerEl.tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should have the outer container with flex column layout', () => {
|
||||
const qa = createQA()
|
||||
const { container } = render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2')
|
||||
})
|
||||
|
||||
it('should apply text styling classes to question paragraph', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const questionEl = screen.getByText(qa.question)
|
||||
expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary')
|
||||
})
|
||||
|
||||
it('should apply text styling classes to answer paragraph', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const answerEl = screen.getByText(qa.answer)
|
||||
expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty question', () => {
|
||||
const qa = createQA({ question: '' })
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty answer', () => {
|
||||
const qa = createQA({ answer: '' })
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText(qa.question)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text', () => {
|
||||
const longText = 'x'.repeat(1000)
|
||||
const qa = createQA({ question: longText, answer: longText })
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const elements = screen.getAllByText(longText)
|
||||
expect(elements).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render with special characters in question and answer', () => {
|
||||
const qa = createQA({
|
||||
question: 'What about <html> & "quotes"?',
|
||||
answer: 'It handles \'single\' & "double" quotes.',
|
||||
})
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('What about <html> & "quotes"?')).toBeInTheDocument()
|
||||
expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiline text', () => {
|
||||
const qa = createQA({
|
||||
question: 'Line1\nLine2',
|
||||
answer: 'Answer1\nAnswer2',
|
||||
})
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText(/Line1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Answer1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import UpgradeCard from './upgrade-card'
|
||||
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock UpgradeBtn (external billing module) to render as a plain button
|
||||
vi.mock('@/app/components/billing/upgrade-btn', () => ({
|
||||
default: ({ onClick, className }: { onClick?: () => void, className?: string }) => (
|
||||
<button type="button" className={className} onClick={onClick} data-testid="upgrade-btn">
|
||||
upgrade
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('UpgradeCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Assert - title and description i18n keys are rendered
|
||||
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the upgrade title text', () => {
|
||||
// Arrange & Act
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the upgrade description text', () => {
|
||||
// Arrange & Act
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/uploadMultipleFiles\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the upgrade button', () => {
|
||||
// Arrange & Act
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call setShowPricingModal when upgrade button is clicked', () => {
|
||||
// Arrange
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call setShowPricingModal without user interaction', () => {
|
||||
// Arrange & Act
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal on each button click', () => {
|
||||
// Arrange
|
||||
render(<UpgradeCard />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should maintain rendering after rerender with same props', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<UpgradeCard />)
|
||||
|
||||
// Act
|
||||
rerender(<UpgradeCard />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,710 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Component Imports (after mocks)
|
||||
// ============================================================================
|
||||
|
||||
import CheckboxWithLabel from './checkbox-with-label'
|
||||
import Crawling from './crawling'
|
||||
import ErrorMessage from './error-message'
|
||||
import Field from './field'
|
||||
import OptionsWrap from './options-wrap'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock ahooks useBoolean for OptionsWrap
|
||||
let mockFoldValue = false
|
||||
const mockToggle = vi.fn()
|
||||
const mockSetTrue = vi.fn()
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => {
|
||||
mockFoldValue = initial
|
||||
return [
|
||||
mockFoldValue,
|
||||
{
|
||||
toggle: mockToggle,
|
||||
setTrue: mockSetTrue,
|
||||
setFalse: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
]
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// CheckboxWithLabel Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CheckboxWithLabel', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the checkbox with label component
|
||||
describe('Rendering', () => {
|
||||
it('should render label text', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Include subpages"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Include subpages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a checkbox element', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Test label"
|
||||
testId="test-cb"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - Checkbox renders as a div with data-testid
|
||||
expect(screen.getByTestId('checkbox-test-cb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render check icon when isChecked is true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={true}
|
||||
onChange={mockOnChange}
|
||||
label="Test label"
|
||||
testId="checked-cb"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - When checked, the Checkbox renders a check icon
|
||||
expect(screen.getByTestId('check-icon-checked-cb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon when isChecked is false', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Test label"
|
||||
testId="unchecked-cb"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('check-icon-unchecked-cb')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip when tooltip prop is provided', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Test"
|
||||
tooltip="Helpful hint"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - Tooltip renders its trigger element
|
||||
const label = screen.getByText('Test').closest('label')
|
||||
expect(label).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when tooltip prop is not provided', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - no tooltip trigger
|
||||
const label = container.querySelector('label')
|
||||
expect(label).toBeInTheDocument()
|
||||
// The tooltip trigger has a specific class; without tooltip, it should not exist
|
||||
expect(container.querySelector('[class*="ml-0.5"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<CheckboxWithLabel
|
||||
className="custom-class"
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('label')
|
||||
expect(label?.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('should apply custom labelClassName', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Styled label"
|
||||
labelClassName="font-bold"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labelText = screen.getByText('Styled label')
|
||||
expect(labelText.className).toContain('font-bold')
|
||||
})
|
||||
|
||||
it('should set testId on checkbox', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Test"
|
||||
testId="my-checkbox"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(document.getElementById('my-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interaction tests
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with true when unchecked checkbox is clicked', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={false}
|
||||
onChange={mockOnChange}
|
||||
label="Toggle me"
|
||||
testId="toggle-cb"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act - Checkbox is a div, click it directly
|
||||
fireEvent.click(screen.getByTestId('checkbox-toggle-cb'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call onChange with false when checked checkbox is clicked', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<CheckboxWithLabel
|
||||
isChecked={true}
|
||||
onChange={mockOnChange}
|
||||
label="Toggle me"
|
||||
testId="toggle-cb2"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('checkbox-toggle-cb2'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Crawling Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Crawling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the crawling status component
|
||||
describe('Rendering', () => {
|
||||
it('should render crawled/total count', () => {
|
||||
// Arrange & Act
|
||||
render(<Crawling crawledNum={5} totalNum={10} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/10/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the total pages scraped label', () => {
|
||||
// Arrange & Act
|
||||
render(<Crawling crawledNum={3} totalNum={20} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render skeleton rows', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Crawling crawledNum={0} totalNum={0} />)
|
||||
|
||||
// Assert - 4 skeleton items rendered
|
||||
const skeletonRows = container.querySelectorAll('.py-\\[5px\\]')
|
||||
expect(skeletonRows).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Crawling className="extra-class" crawledNum={1} totalNum={5} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild?.className).toContain('extra-class')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case tests
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with zero values', () => {
|
||||
// Arrange & Act
|
||||
render(<Crawling crawledNum={0} totalNum={0} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/0/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when crawledNum equals totalNum', () => {
|
||||
// Arrange & Act
|
||||
render(<Crawling crawledNum={10} totalNum={10} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/10/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ErrorMessage Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ErrorMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the error message component
|
||||
describe('Rendering', () => {
|
||||
it('should render the title', () => {
|
||||
// Arrange & Act
|
||||
render(<ErrorMessage title="Something went wrong" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the alert triangle icon', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ErrorMessage title="Error" />)
|
||||
|
||||
// Assert - AlertTriangle icon is rendered as svg
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render errorMsg when provided', () => {
|
||||
// Arrange & Act
|
||||
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render errorMsg when not provided', () => {
|
||||
// Arrange & Act
|
||||
render(<ErrorMessage title="Error" />)
|
||||
|
||||
// Assert - only one text block (the title)
|
||||
expect(screen.queryByText('Detailed error info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ErrorMessage className="my-error" title="Error" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild?.className).toContain('my-error')
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage for optional errorMsg
|
||||
describe('Branch Coverage', () => {
|
||||
it('should render error detail section when errorMsg is truthy', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ErrorMessage title="Error" errorMsg="Details here" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const errorDetail = container.querySelector('.pl-6')
|
||||
expect(errorDetail).toBeInTheDocument()
|
||||
expect(errorDetail?.textContent).toBe('Details here')
|
||||
})
|
||||
|
||||
it('should not render error detail section when errorMsg is empty string', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ErrorMessage title="Error" errorMsg="" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const errorDetail = container.querySelector('.pl-6')
|
||||
expect(errorDetail).toBeNull()
|
||||
})
|
||||
|
||||
it('should not render error detail section when errorMsg is undefined', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ErrorMessage title="Error" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const errorDetail = container.querySelector('.pl-6')
|
||||
expect(errorDetail).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Field Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Field', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the field component
|
||||
describe('Rendering', () => {
|
||||
it('should render the label', () => {
|
||||
// Arrange & Act
|
||||
render(<Field label="Max depth" value="" onChange={mockOnChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Max depth')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the input with value', () => {
|
||||
// Arrange & Act
|
||||
render(<Field label="URL" value="https://example.com" onChange={mockOnChange} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('https://example.com')
|
||||
})
|
||||
|
||||
it('should render placeholder text', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Field
|
||||
label="Field"
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
placeholder="Enter value"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render required marker when isRequired is true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Field label="Required field" value="" onChange={mockOnChange} isRequired />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('*')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render required marker when isRequired is false', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Field label="Optional field" value="" onChange={mockOnChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip when tooltip prop is provided', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Field
|
||||
label="Field"
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
tooltip="Help text"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - tooltip trigger is present
|
||||
expect(container.querySelector('[class*="ml-0.5"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when tooltip prop is not provided', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Field label="Field" value="" onChange={mockOnChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('[class*="ml-0.5"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Field className="custom-field" label="Field" value="" onChange={mockOnChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild?.className).toContain('custom-field')
|
||||
})
|
||||
|
||||
it('should apply custom labelClassName', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Field
|
||||
label="Styled"
|
||||
labelClassName="extra-label-style"
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labelDiv = screen.getByText('Styled')
|
||||
expect(labelDiv.className).toContain('extra-label-style')
|
||||
})
|
||||
})
|
||||
|
||||
// User interaction tests for the field component
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when text input value changes', () => {
|
||||
// Arrange
|
||||
render(<Field label="Text field" value="" onChange={mockOnChange} />)
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new value' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should call onChange with number when isNumber is true', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Field label="Number field" value="" onChange={mockOnChange} isNumber />,
|
||||
)
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '42' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnChange).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('should call onChange with empty string for NaN input when isNumber is true', () => {
|
||||
// Arrange — start with a numeric value so clearing triggers a real change
|
||||
render(
|
||||
<Field label="Number" value={10} onChange={mockOnChange} isNumber />,
|
||||
)
|
||||
|
||||
// Act — clear the number input
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should clamp to MIN_VALUE (0) for negative numbers when isNumber is true', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Field label="Number" value="" onChange={mockOnChange} isNumber />,
|
||||
)
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '-5' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases for the field component
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with number type input when isNumber is true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Field label="Num" value={5} onChange={mockOnChange} isNumber />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('type', 'number')
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
})
|
||||
|
||||
it('should render with text type input when isNumber is false', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Field label="Text" value="hello" onChange={mockOnChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('type', 'text')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// OptionsWrap Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('OptionsWrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFoldValue = false
|
||||
})
|
||||
|
||||
// Rendering tests for the options wrap component
|
||||
describe('Rendering', () => {
|
||||
it('should render the options label', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<OptionsWrap>
|
||||
<div>Child content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/options/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children when not folded (default state)', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<OptionsWrap>
|
||||
<div>Visible options</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Assert - fold is false by default, so children are visible
|
||||
expect(screen.getByText('Visible options')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the settings icon', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<OptionsWrap>
|
||||
<div>Content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Assert - RiEqualizer2Line renders an svg
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<OptionsWrap className="extra">
|
||||
<div>Content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild?.className).toContain('extra')
|
||||
})
|
||||
})
|
||||
|
||||
// User interaction tests for the options wrap component
|
||||
describe('User Interactions', () => {
|
||||
it('should call toggle when header is clicked', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<OptionsWrap>
|
||||
<div>Content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const header = screen.getByText(/options/i).closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
fireEvent.click(header)
|
||||
|
||||
// Assert
|
||||
expect(mockToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// controlFoldOptions prop tests
|
||||
describe('controlFoldOptions', () => {
|
||||
it('should call foldHide when controlFoldOptions changes', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<OptionsWrap controlFoldOptions={0}>
|
||||
<div>Content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Act - change controlFoldOptions to trigger the useEffect
|
||||
rerender(
|
||||
<OptionsWrap controlFoldOptions={1}>
|
||||
<div>Content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockSetTrue).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call foldHide when controlFoldOptions is falsy (0)', () => {
|
||||
// Arrange
|
||||
mockSetTrue.mockClear()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<OptionsWrap controlFoldOptions={0}>
|
||||
<div>Content</div>
|
||||
</OptionsWrap>,
|
||||
)
|
||||
|
||||
// Assert - controlFoldOptions is 0 (falsy), so foldHide is not called
|
||||
expect(mockSetTrue).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
230
web/app/components/datasets/create/website/no-data.spec.tsx
Normal file
230
web/app/components/datasets/create/website/no-data.spec.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import NoData from './no-data'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock CSS module
|
||||
vi.mock('./index.module.css', () => ({
|
||||
default: {
|
||||
jinaLogo: 'jinaLogo',
|
||||
watercrawlLogo: 'watercrawlLogo',
|
||||
},
|
||||
}))
|
||||
|
||||
// Feature flags - default all enabled
|
||||
let mockEnableFirecrawl = true
|
||||
let mockEnableJinaReader = true
|
||||
let mockEnableWaterCrawl = true
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl },
|
||||
get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader },
|
||||
get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWaterCrawl },
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// NoData Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('NoData', () => {
|
||||
const mockOnConfig = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableFirecrawl = true
|
||||
mockEnableJinaReader = true
|
||||
mockEnableWaterCrawl = true
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests - Per Provider
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering per provider', () => {
|
||||
it('should render fireCrawl provider with emoji and not-configured message', () => {
|
||||
// Arrange & Act
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('🔥')).toBeInTheDocument()
|
||||
const titleAndDesc = screen.getAllByText(/fireCrawlNotConfigured/i)
|
||||
expect(titleAndDesc).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render jinaReader provider with jina logo and not-configured message', () => {
|
||||
// Arrange & Act
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
|
||||
|
||||
// Assert
|
||||
const titleAndDesc = screen.getAllByText(/jinaReaderNotConfigured/i)
|
||||
expect(titleAndDesc).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render waterCrawl provider with emoji and not-configured message', () => {
|
||||
// Arrange & Act
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('💧')).toBeInTheDocument()
|
||||
const titleAndDesc = screen.getAllByText(/waterCrawlNotConfigured/i)
|
||||
expect(titleAndDesc).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render configure button for each provider', () => {
|
||||
// Arrange & Act
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /configure/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onConfig when configure button is clicked', () => {
|
||||
// Arrange
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfig for jinaReader provider', () => {
|
||||
// Arrange
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfig for waterCrawl provider', () => {
|
||||
// Arrange
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Feature Flag Disabled - Returns null
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Disabled providers (feature flag off)', () => {
|
||||
it('should fall back to jinaReader when fireCrawl is disabled but jinaReader enabled', () => {
|
||||
// Arrange — fireCrawl config is null, falls back to providerConfig.jinareader
|
||||
mockEnableFirecrawl = false
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
|
||||
)
|
||||
|
||||
// Assert — renders the jinaReader fallback (not null)
|
||||
expect(container.innerHTML).not.toBe('')
|
||||
expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return null when jinaReader is disabled', () => {
|
||||
// Arrange — jinaReader is the only provider without a fallback
|
||||
mockEnableJinaReader = false
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back to jinaReader when waterCrawl is disabled but jinaReader enabled', () => {
|
||||
// Arrange — waterCrawl config is null, falls back to providerConfig.jinareader
|
||||
mockEnableWaterCrawl = false
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />,
|
||||
)
|
||||
|
||||
// Assert — renders the jinaReader fallback (not null)
|
||||
expect(container.innerHTML).not.toBe('')
|
||||
expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Fallback behavior
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Fallback behavior', () => {
|
||||
it('should fall back to jinaReader config for unknown provider value', () => {
|
||||
// Arrange - the || fallback goes to providerConfig.jinareader
|
||||
// Since DataSourceProvider only has 3 values, we test the fallback
|
||||
// by checking that jinaReader is the fallback when provider doesn't match
|
||||
mockEnableJinaReader = true
|
||||
|
||||
// Act
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should not call onConfig without user interaction', () => {
|
||||
// Arrange & Act
|
||||
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render correctly when all providers are enabled', () => {
|
||||
// Arrange - all flags are true by default
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
|
||||
)
|
||||
expect(screen.getByText('🔥')).toBeInTheDocument()
|
||||
|
||||
rerender(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
|
||||
expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0)
|
||||
|
||||
rerender(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
|
||||
expect(screen.getByText('💧')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when all providers are disabled and fireCrawl is selected', () => {
|
||||
// Arrange
|
||||
mockEnableFirecrawl = false
|
||||
mockEnableJinaReader = false
|
||||
mockEnableWaterCrawl = false
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
256
web/app/components/datasets/create/website/preview.spec.tsx
Normal file
256
web/app/components/datasets/create/website/preview.spec.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebsitePreview from './preview'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock the CSS module import - returns class names as-is
|
||||
vi.mock('../file-preview/index.module.css', () => ({
|
||||
default: {
|
||||
filePreview: 'filePreview',
|
||||
previewHeader: 'previewHeader',
|
||||
title: 'title',
|
||||
previewContent: 'previewContent',
|
||||
fileContent: 'fileContent',
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factory
|
||||
// ============================================================================
|
||||
|
||||
const createPayload = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
|
||||
title: 'Test Page Title',
|
||||
markdown: 'This is **markdown** content',
|
||||
description: 'A test description',
|
||||
source_url: 'https://example.com/page',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// WebsitePreview Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('WebsitePreview', () => {
|
||||
const mockHidePreview = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const payload = createPayload()
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the page preview header text', () => {
|
||||
// Arrange
|
||||
const payload = createPayload()
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert - i18n returns the key path
|
||||
expect(screen.getByText(/pagePreview/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the payload title', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ title: 'My Custom Page' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('My Custom Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the payload source_url', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ source_url: 'https://docs.dify.ai/intro' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
const urlElement = screen.getByText('https://docs.dify.ai/intro')
|
||||
expect(urlElement).toBeInTheDocument()
|
||||
expect(urlElement).toHaveAttribute('title', 'https://docs.dify.ai/intro')
|
||||
})
|
||||
|
||||
it('should render the payload markdown content', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ markdown: 'Hello world markdown' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Hello world markdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close button (XMarkIcon)', () => {
|
||||
// Arrange
|
||||
const payload = createPayload()
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert - the close button container is a div with cursor-pointer
|
||||
const closeButton = screen.getByText(/pagePreview/i).parentElement?.querySelector('.cursor-pointer')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call hidePreview when close button is clicked', () => {
|
||||
// Arrange
|
||||
const payload = createPayload()
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Act - find the close button div with cursor-pointer class
|
||||
const closeButton = screen.getByText(/pagePreview/i)
|
||||
.closest('[class*="title"]')!
|
||||
.querySelector('.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockHidePreview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call hidePreview exactly once per click', () => {
|
||||
// Arrange
|
||||
const payload = createPayload()
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Act
|
||||
const closeButton = screen.getByText(/pagePreview/i)
|
||||
.closest('[class*="title"]')!
|
||||
.querySelector('.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(closeButton)
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockHidePreview).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Display', () => {
|
||||
it('should display all payload fields simultaneously', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({
|
||||
title: 'Full Title',
|
||||
source_url: 'https://full.example.com',
|
||||
markdown: 'Full markdown text',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Full Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://full.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Full markdown text')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty title', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ title: '' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert - component still renders, url is visible
|
||||
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty markdown', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ markdown: '' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty source_url', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ source_url: '' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with very long content', () => {
|
||||
// Arrange
|
||||
const longMarkdown = 'A'.repeat(5000)
|
||||
const payload = createPayload({ markdown: longMarkdown })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longMarkdown)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with special characters in title', () => {
|
||||
// Arrange
|
||||
const payload = createPayload({ title: '<script>alert("xss")</script>' })
|
||||
|
||||
// Act
|
||||
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
|
||||
|
||||
// Assert - React escapes HTML by default
|
||||
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CSS Module Classes
|
||||
// --------------------------------------------------------------------------
|
||||
describe('CSS Module Classes', () => {
|
||||
it('should apply filePreview class to root container', () => {
|
||||
// Arrange
|
||||
const payload = createPayload()
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<WebsitePreview payload={payload} hidePreview={mockHidePreview} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const root = container.firstElementChild
|
||||
expect(root?.className).toContain('filePreview')
|
||||
expect(root?.className).toContain('h-full')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SummaryLabel from './summary-label'
|
||||
import SummaryStatus from './summary-status'
|
||||
import SummaryText from './summary-text'
|
||||
|
||||
describe('SummaryLabel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies the component renders with its heading and summary text
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SummaryLabel />)
|
||||
expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the summary heading with divider', () => {
|
||||
render(<SummaryLabel summary="Test summary" />)
|
||||
expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render summary text when provided', () => {
|
||||
render(<SummaryLabel summary="My summary content" />)
|
||||
expect(screen.getByText('My summary content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests different prop combinations
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<SummaryLabel summary="test" className="custom-class" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
expect(wrapper).toHaveClass('space-y-1')
|
||||
})
|
||||
|
||||
it('should render without className prop', () => {
|
||||
const { container } = render(<SummaryLabel summary="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('space-y-1')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests undefined/empty/special values
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined summary', () => {
|
||||
render(<SummaryLabel />)
|
||||
// Heading should still render
|
||||
expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string summary', () => {
|
||||
render(<SummaryLabel summary="" />)
|
||||
expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle summary with special characters', () => {
|
||||
const summary = '<b>bold</b> & "quotes"'
|
||||
render(<SummaryLabel summary={summary} />)
|
||||
expect(screen.getByText(summary)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long summary', () => {
|
||||
const longSummary = 'A'.repeat(1000)
|
||||
render(<SummaryLabel summary={longSummary} />)
|
||||
expect(screen.getByText(longSummary)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle both className and summary as undefined', () => {
|
||||
const { container } = render(<SummaryLabel />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('space-y-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SummaryStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies badge rendering based on status
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SummaryStatus status="COMPLETED" />)
|
||||
// Should not crash even for non-SUMMARIZING status
|
||||
})
|
||||
|
||||
it('should render badge when status is SUMMARIZING', () => {
|
||||
render(<SummaryStatus status="SUMMARIZING" />)
|
||||
expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render badge when status is not SUMMARIZING', () => {
|
||||
render(<SummaryStatus status="COMPLETED" />)
|
||||
expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests tooltip content based on status
|
||||
describe('Props', () => {
|
||||
it('should show tooltip with generating summary message when SUMMARIZING', () => {
|
||||
render(<SummaryStatus status="SUMMARIZING" />)
|
||||
// The tooltip popupContent is set to the i18n key for generatingSummary
|
||||
expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests different status values
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render badge for empty string status', () => {
|
||||
render(<SummaryStatus status="" />)
|
||||
expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render badge for lowercase summarizing', () => {
|
||||
render(<SummaryStatus status="summarizing" />)
|
||||
expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render badge for DONE status', () => {
|
||||
render(<SummaryStatus status="DONE" />)
|
||||
expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render badge for FAILED status', () => {
|
||||
render(<SummaryStatus status="FAILED" />)
|
||||
expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SummaryText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies the label and textarea render correctly
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SummaryText />)
|
||||
expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the summary label', () => {
|
||||
render(<SummaryText value="hello" />)
|
||||
expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textarea with placeholder', () => {
|
||||
render(<SummaryText />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveAttribute('placeholder', expect.stringContaining('segment.summaryPlaceholder'))
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests value, onChange, and disabled behavior
|
||||
describe('Props', () => {
|
||||
it('should display the value prop in textarea', () => {
|
||||
render(<SummaryText value="My summary" />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('My summary')
|
||||
})
|
||||
|
||||
it('should display empty string when value is undefined', () => {
|
||||
render(<SummaryText />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should call onChange when textarea value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SummaryText value="" onChange={onChange} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'new value' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should disable textarea when disabled is true', () => {
|
||||
render(<SummaryText value="test" disabled={true} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable textarea when disabled is false', () => {
|
||||
render(<SummaryText value="test" disabled={false} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable textarea when disabled is undefined', () => {
|
||||
render(<SummaryText value="test" />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests missing onChange and edge value scenarios
|
||||
describe('Edge Cases', () => {
|
||||
it('should not throw when onChange is undefined and user types', () => {
|
||||
render(<SummaryText value="" />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(() => {
|
||||
fireEvent.change(textarea, { target: { value: 'typed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle empty string value', () => {
|
||||
render(<SummaryText value="" />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle very long value', () => {
|
||||
const longValue = 'B'.repeat(5000)
|
||||
render(<SummaryText value={longValue} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue(longValue)
|
||||
})
|
||||
|
||||
it('should handle value with special characters', () => {
|
||||
const special = '<script>alert("x")</script>'
|
||||
render(<SummaryText value={special} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue(special)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,439 @@
|
||||
import type { DocumentListQuery } from './use-document-list-query-state'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useDocumentListQueryState from './use-document-list-query-state'
|
||||
|
||||
// Mock external dependencies
|
||||
const mockPush = vi.fn()
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/datasets/test-id/documents',
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock search params to empty
|
||||
for (const key of [...mockSearchParams.keys()])
|
||||
mockSearchParams.delete(key)
|
||||
})
|
||||
|
||||
// Tests for parseParams (exposed via the query property)
|
||||
describe('parseParams (via query)', () => {
|
||||
it('should return default query when no search params present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse page from search params', () => {
|
||||
mockSearchParams.set('page', '3')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is zero', () => {
|
||||
mockSearchParams.set('page', '0')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is negative', () => {
|
||||
mockSearchParams.set('page', '-5')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is NaN', () => {
|
||||
mockSearchParams.set('page', 'abc')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse limit from search params', () => {
|
||||
mockSearchParams.set('limit', '50')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(50)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is zero', () => {
|
||||
mockSearchParams.set('limit', '0')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit exceeds 100', () => {
|
||||
mockSearchParams.set('limit', '101')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is negative', () => {
|
||||
mockSearchParams.set('limit', '-1')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 100', () => {
|
||||
mockSearchParams.set('limit', '100')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(100)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 1', () => {
|
||||
mockSearchParams.set('limit', '1')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse and decode keyword from search params', () => {
|
||||
mockSearchParams.set('keyword', encodeURIComponent('hello world'))
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.keyword).toBe('hello world')
|
||||
})
|
||||
|
||||
it('should return empty keyword when not present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should sanitize status from search params', () => {
|
||||
mockSearchParams.set('status', 'available')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should fallback status to all for unknown status', () => {
|
||||
mockSearchParams.set('status', 'badvalue')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('all')
|
||||
})
|
||||
|
||||
it('should resolve active status alias to available', () => {
|
||||
mockSearchParams.set('status', 'active')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should parse valid sort value from search params', () => {
|
||||
mockSearchParams.set('sort', 'hit_count')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at for invalid sort value', () => {
|
||||
mockSearchParams.set('sort', 'invalid_sort')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at when not present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'-created_at',
|
||||
'created_at',
|
||||
'-hit_count',
|
||||
'hit_count',
|
||||
] as const)('should accept valid sort value %s', (sortValue) => {
|
||||
mockSearchParams.set('sort', sortValue)
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe(sortValue)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for updateQuery
|
||||
describe('updateQuery', () => {
|
||||
it('should call router.push with updated params when page is changed', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 3 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1)
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=3')
|
||||
})
|
||||
|
||||
it('should call router.push with scroll false', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('should set status in URL when status is not all', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'error' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('status=error')
|
||||
})
|
||||
|
||||
it('should not set status in URL when status is all', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'all' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('status=')
|
||||
})
|
||||
|
||||
it('should set sort in URL when sort is not the default -created_at', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'hit_count' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('sort=hit_count')
|
||||
})
|
||||
|
||||
it('should not set sort in URL when sort is default -created_at', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: '-created_at' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('sort=')
|
||||
})
|
||||
|
||||
it('should encode keyword in URL when keyword is provided', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test query' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
// updateSearchParams calls encodeURIComponent, then URLSearchParams.toString() encodes again
|
||||
expect(pushedUrl).toContain('keyword=')
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1])
|
||||
expect(decodeURIComponent(params.get('keyword')!)).toBe('test query')
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword is empty', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('keyword=')
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all and not include in URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'invalidstatus' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('status=')
|
||||
})
|
||||
|
||||
it('should sanitize invalid sort to -created_at and not include in URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('sort=')
|
||||
})
|
||||
|
||||
it('should omit page and limit when they are default and no keyword', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 1, limit: 10 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('page=')
|
||||
expect(pushedUrl).not.toContain('limit=')
|
||||
})
|
||||
|
||||
it('should include page and limit when page is greater than 1', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=2')
|
||||
expect(pushedUrl).toContain('limit=10')
|
||||
})
|
||||
|
||||
it('should include page and limit when limit is non-default', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 25 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=1')
|
||||
expect(pushedUrl).toContain('limit=25')
|
||||
})
|
||||
|
||||
it('should include page and limit when keyword is provided', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'search' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=1')
|
||||
expect(pushedUrl).toContain('limit=10')
|
||||
})
|
||||
|
||||
it('should use pathname prefix in pushed URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toMatch(/^\/datasets\/test-id\/documents/)
|
||||
})
|
||||
|
||||
it('should push path without query string when all values are defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({})
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toBe('/datasets/test-id/documents')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for resetQuery
|
||||
describe('resetQuery', () => {
|
||||
it('should push URL with default query params when called', () => {
|
||||
mockSearchParams.set('page', '5')
|
||||
mockSearchParams.set('status', 'error')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1)
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
// Default query has all defaults, so no params should be in the URL
|
||||
expect(pushedUrl).toBe('/datasets/test-id/documents')
|
||||
})
|
||||
|
||||
it('should call router.push with scroll false when resetting', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for return value stability
|
||||
describe('return value', () => {
|
||||
it('should return query, updateQuery, and resetQuery', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current).toHaveProperty('query')
|
||||
expect(result.current).toHaveProperty('updateQuery')
|
||||
expect(result.current).toHaveProperty('resetQuery')
|
||||
expect(typeof result.current.updateQuery).toBe('function')
|
||||
expect(typeof result.current.resetQuery).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,711 @@
|
||||
import type { DocumentListQuery } from './use-document-list-query-state'
|
||||
import type { DocumentListResponse } from '@/models/datasets'
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDocumentsPageState } from './use-documents-page-state'
|
||||
|
||||
// Mock external dependencies
|
||||
const mockUpdateQuery = vi.fn()
|
||||
const mockResetQuery = vi.fn()
|
||||
let mockQuery: DocumentListQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' }
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/datasets/test-id/documents',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock ahooks debounce utilities
|
||||
let capturedDebounceFnCallback: (() => void) | null = null
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: unknown, _options?: { wait?: number }) => value,
|
||||
useDebounceFn: (fn: () => void, _options?: { wait?: number }) => {
|
||||
capturedDebounceFnCallback = fn
|
||||
return { run: fn, cancel: vi.fn(), flush: vi.fn() }
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the dependent hook
|
||||
vi.mock('./use-document-list-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQuery,
|
||||
updateQuery: mockUpdateQuery,
|
||||
resetQuery: mockResetQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Factory for creating DocumentListResponse test data
|
||||
function createDocumentListResponse(overrides: Partial<DocumentListResponse> = {}): DocumentListResponse {
|
||||
return {
|
||||
data: [],
|
||||
has_more: false,
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Factory for creating a minimal document item
|
||||
function createDocumentItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
indexing_status: 'completed' as string,
|
||||
display_status: 'available' as string,
|
||||
enabled: true,
|
||||
archived: false,
|
||||
word_count: 100,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
created_from: 'web' as const,
|
||||
created_by: 'user-1',
|
||||
dataset_process_rule_id: 'rule-1',
|
||||
doc_form: 'text_model' as const,
|
||||
doc_language: 'en',
|
||||
position: 1,
|
||||
data_source_type: 'upload_file',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDocumentsPageState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedDebounceFnCallback = null
|
||||
mockQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' }
|
||||
})
|
||||
|
||||
// Initial state verification
|
||||
describe('initial state', () => {
|
||||
it('should return correct initial search state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.inputValue).toBe('')
|
||||
expect(result.current.searchValue).toBe('')
|
||||
expect(result.current.debouncedSearchValue).toBe('')
|
||||
})
|
||||
|
||||
it('should return correct initial filter and sort state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(result.current.sortValue).toBe('-created_at')
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('all')
|
||||
})
|
||||
|
||||
it('should return correct initial pagination state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// page is query.page - 1 = 0
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(result.current.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should return correct initial selection state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.selectedIds).toEqual([])
|
||||
})
|
||||
|
||||
it('should return correct initial polling state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should initialize from query when query has keyword', () => {
|
||||
mockQuery = { ...mockQuery, keyword: 'initial search' }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.inputValue).toBe('initial search')
|
||||
expect(result.current.searchValue).toBe('initial search')
|
||||
})
|
||||
|
||||
it('should initialize pagination from query with non-default page', () => {
|
||||
mockQuery = { ...mockQuery, page: 3, limit: 25 }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.currPage).toBe(2) // page - 1
|
||||
expect(result.current.limit).toBe(25)
|
||||
})
|
||||
|
||||
it('should initialize status filter from query', () => {
|
||||
mockQuery = { ...mockQuery, status: 'error' }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('error')
|
||||
})
|
||||
|
||||
it('should initialize sort from query', () => {
|
||||
mockQuery = { ...mockQuery, sort: 'hit_count' }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.sortValue).toBe('hit_count')
|
||||
})
|
||||
})
|
||||
|
||||
// Handler behaviors
|
||||
describe('handleInputChange', () => {
|
||||
it('should update input value when called', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputChange('new value')
|
||||
})
|
||||
|
||||
expect(result.current.inputValue).toBe('new value')
|
||||
})
|
||||
|
||||
it('should trigger debounced search callback when called', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// First call sets inputValue and triggers the debounced fn
|
||||
act(() => {
|
||||
result.current.handleInputChange('search term')
|
||||
})
|
||||
|
||||
// The debounced fn captures inputValue from its render closure.
|
||||
// After re-render with new inputValue, calling the captured callback again
|
||||
// should reflect the updated state.
|
||||
act(() => {
|
||||
if (capturedDebounceFnCallback)
|
||||
capturedDebounceFnCallback()
|
||||
})
|
||||
|
||||
expect(result.current.searchValue).toBe('search term')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleStatusFilterChange', () => {
|
||||
it('should update status filter value when called with valid status', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('error')
|
||||
})
|
||||
|
||||
it('should reset page to 0 when status filter changes', () => {
|
||||
mockQuery = { ...mockQuery, page: 3 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should call updateQuery with sanitized status and page 1', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'error', page: 1 })
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('invalid')
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleStatusFilterClear', () => {
|
||||
it('should set status to all and reset page when status is not all', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// First set a non-all status
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Then clear
|
||||
act(() => {
|
||||
result.current.handleStatusFilterClear()
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
|
||||
})
|
||||
|
||||
it('should not call updateQuery when status is already all', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterClear()
|
||||
})
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSortChange', () => {
|
||||
it('should update sort value and call updateQuery when value changes', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('hit_count')
|
||||
})
|
||||
|
||||
expect(result.current.sortValue).toBe('hit_count')
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ sort: 'hit_count', page: 1 })
|
||||
})
|
||||
|
||||
it('should reset page to 0 when sort changes', () => {
|
||||
mockQuery = { ...mockQuery, page: 5 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('hit_count')
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should not call updateQuery when sort value is same as current', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('-created_at')
|
||||
})
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePageChange', () => {
|
||||
it('should update current page and call updateQuery', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePageChange(2)
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(2)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // newPage + 1
|
||||
})
|
||||
|
||||
it('should handle page 0 (first page)', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePageChange(0)
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLimitChange', () => {
|
||||
it('should update limit, reset page to 0, and call updateQuery', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleLimitChange(25)
|
||||
})
|
||||
|
||||
expect(result.current.limit).toBe(25)
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ limit: 25, page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
// Selection state
|
||||
describe('selection state', () => {
|
||||
it('should update selectedIds via setSelectedIds', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedIds(['doc-1', 'doc-2'])
|
||||
})
|
||||
|
||||
expect(result.current.selectedIds).toEqual(['doc-1', 'doc-2'])
|
||||
})
|
||||
})
|
||||
|
||||
// Polling state management
|
||||
describe('updatePollingState', () => {
|
||||
it('should not update timer when documentsRes is undefined', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(undefined)
|
||||
})
|
||||
|
||||
// timerCanRun remains true (initial value)
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should not update timer when documentsRes.data is undefined', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState({ data: undefined } as unknown as DocumentListResponse)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should set timerCanRun to false when all documents are completed and status filter is all', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should set timerCanRun to true when some documents are not completed', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
createDocumentItem({ indexing_status: 'indexing' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should count paused documents as completed for polling purposes', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'paused' }),
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// All docs are "embedded" (completed, paused, error), so hasIncomplete = false
|
||||
// statusFilter is 'all', so shouldForcePolling = false
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should count error documents as completed for polling purposes', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'error' }),
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should force polling when status filter is a transient status (queuing)', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// Set status filter to queuing
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('queuing')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// shouldForcePolling = true (queuing is transient), hasIncomplete = false
|
||||
// timerCanRun = true || false = true
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should force polling when status filter is indexing', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('indexing')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should force polling when status filter is paused', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('paused')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'paused' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should not force polling when status filter is a non-transient status (error)', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'error' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// shouldForcePolling = false (error is not transient), hasIncomplete = false (error is embedded)
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should set timerCanRun to true when data is empty and filter is transient', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('indexing')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({ data: [] as DocumentListResponse['data'], total: 0 })
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// shouldForcePolling = true (indexing is transient), hasIncomplete = false (0 !== 0 is false)
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Page adjustment
|
||||
describe('adjustPageForTotal', () => {
|
||||
it('should not adjust page when documentsRes is undefined', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should not adjust page when currPage is within total pages', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
const response = createDocumentListResponse({ total: 20 })
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// currPage is 0, totalPages is 2, so no adjustment needed
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should adjust page to last page when currPage exceeds total pages', () => {
|
||||
mockQuery = { ...mockQuery, page: 6 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// currPage should be 5 (page - 1)
|
||||
expect(result.current.currPage).toBe(5)
|
||||
|
||||
const response = createDocumentListResponse({ total: 30 }) // 30/10 = 3 pages
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// currPage (5) + 1 > totalPages (3), so adjust to totalPages - 1 = 2
|
||||
expect(result.current.currPage).toBe(2)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // handlePageChange passes newPage + 1
|
||||
})
|
||||
|
||||
it('should adjust page to 0 when total is 0 and currPage > 0', () => {
|
||||
mockQuery = { ...mockQuery, page: 3 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
const response = createDocumentListResponse({ total: 0 })
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// totalPages = 0, so adjust to max(0 - 1, 0) = 0
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 })
|
||||
})
|
||||
|
||||
it('should not adjust page when currPage is 0 even if total is 0', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
const response = createDocumentListResponse({ total: 0 })
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// currPage is 0, condition is currPage > 0 so no adjustment
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Normalized status filter value
|
||||
describe('normalizedStatusFilterValue', () => {
|
||||
it('should return all for default status', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('all')
|
||||
})
|
||||
|
||||
it('should normalize enabled to available', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('enabled')
|
||||
})
|
||||
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('available')
|
||||
})
|
||||
|
||||
it('should return non-aliased status as-is', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
// Return value shape
|
||||
describe('return value', () => {
|
||||
it('should return all expected properties', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// Search state
|
||||
expect(result.current).toHaveProperty('inputValue')
|
||||
expect(result.current).toHaveProperty('searchValue')
|
||||
expect(result.current).toHaveProperty('debouncedSearchValue')
|
||||
expect(result.current).toHaveProperty('handleInputChange')
|
||||
|
||||
// Filter & sort state
|
||||
expect(result.current).toHaveProperty('statusFilterValue')
|
||||
expect(result.current).toHaveProperty('sortValue')
|
||||
expect(result.current).toHaveProperty('normalizedStatusFilterValue')
|
||||
expect(result.current).toHaveProperty('handleStatusFilterChange')
|
||||
expect(result.current).toHaveProperty('handleStatusFilterClear')
|
||||
expect(result.current).toHaveProperty('handleSortChange')
|
||||
|
||||
// Pagination state
|
||||
expect(result.current).toHaveProperty('currPage')
|
||||
expect(result.current).toHaveProperty('limit')
|
||||
expect(result.current).toHaveProperty('handlePageChange')
|
||||
expect(result.current).toHaveProperty('handleLimitChange')
|
||||
|
||||
// Selection state
|
||||
expect(result.current).toHaveProperty('selectedIds')
|
||||
expect(result.current).toHaveProperty('setSelectedIds')
|
||||
|
||||
// Polling state
|
||||
expect(result.current).toHaveProperty('timerCanRun')
|
||||
expect(result.current).toHaveProperty('updatePollingState')
|
||||
expect(result.current).toHaveProperty('adjustPageForTotal')
|
||||
})
|
||||
|
||||
it('should have function types for all handlers', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(typeof result.current.handleInputChange).toBe('function')
|
||||
expect(typeof result.current.handleStatusFilterChange).toBe('function')
|
||||
expect(typeof result.current.handleStatusFilterClear).toBe('function')
|
||||
expect(typeof result.current.handleSortChange).toBe('function')
|
||||
expect(typeof result.current.handlePageChange).toBe('function')
|
||||
expect(typeof result.current.handleLimitChange).toBe('function')
|
||||
expect(typeof result.current.setSelectedIds).toBe('function')
|
||||
expect(typeof result.current.updatePollingState).toBe('function')
|
||||
expect(typeof result.current.adjustPageForTotal).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
156
web/app/components/datasets/documents/status-filter.spec.ts
Normal file
156
web/app/components/datasets/documents/status-filter.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { normalizeStatusForQuery, sanitizeStatusValue } from './status-filter'
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
describe('status-filter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for sanitizeStatusValue
|
||||
describe('sanitizeStatusValue', () => {
|
||||
// Falsy inputs should return 'all'
|
||||
describe('falsy inputs', () => {
|
||||
it('should return all when value is undefined', () => {
|
||||
expect(sanitizeStatusValue(undefined)).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is null', () => {
|
||||
expect(sanitizeStatusValue(null)).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is empty string', () => {
|
||||
expect(sanitizeStatusValue('')).toBe('all')
|
||||
})
|
||||
})
|
||||
|
||||
// Known status values should be returned as-is (lowercased)
|
||||
describe('known status values', () => {
|
||||
it('should return all when value is all', () => {
|
||||
expect(sanitizeStatusValue('all')).toBe('all')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
])('should return %s when value is %s', (status) => {
|
||||
expect(sanitizeStatusValue(status)).toBe(status)
|
||||
})
|
||||
|
||||
it('should handle uppercase known values by normalizing to lowercase', () => {
|
||||
expect(sanitizeStatusValue('QUEUING')).toBe('queuing')
|
||||
expect(sanitizeStatusValue('Available')).toBe('available')
|
||||
expect(sanitizeStatusValue('ALL')).toBe('all')
|
||||
})
|
||||
})
|
||||
|
||||
// URL alias resolution
|
||||
describe('URL aliases', () => {
|
||||
it('should resolve active to available', () => {
|
||||
expect(sanitizeStatusValue('active')).toBe('available')
|
||||
})
|
||||
|
||||
it('should resolve Active (uppercase) to available', () => {
|
||||
expect(sanitizeStatusValue('Active')).toBe('available')
|
||||
})
|
||||
|
||||
it('should resolve ACTIVE to available', () => {
|
||||
expect(sanitizeStatusValue('ACTIVE')).toBe('available')
|
||||
})
|
||||
})
|
||||
|
||||
// Unknown values should fall back to 'all'
|
||||
describe('unknown values', () => {
|
||||
it('should return all when value is unknown', () => {
|
||||
expect(sanitizeStatusValue('unknown')).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is an arbitrary string', () => {
|
||||
expect(sanitizeStatusValue('foobar')).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is a numeric string', () => {
|
||||
expect(sanitizeStatusValue('123')).toBe('all')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for normalizeStatusForQuery
|
||||
describe('normalizeStatusForQuery', () => {
|
||||
// When sanitized value is 'all', should return 'all'
|
||||
describe('all status', () => {
|
||||
it('should return all when value is undefined', () => {
|
||||
expect(normalizeStatusForQuery(undefined)).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is null', () => {
|
||||
expect(normalizeStatusForQuery(null)).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is empty string', () => {
|
||||
expect(normalizeStatusForQuery('')).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is all', () => {
|
||||
expect(normalizeStatusForQuery('all')).toBe('all')
|
||||
})
|
||||
|
||||
it('should return all when value is unknown (sanitized to all)', () => {
|
||||
expect(normalizeStatusForQuery('unknown')).toBe('all')
|
||||
})
|
||||
})
|
||||
|
||||
// Query alias resolution: enabled -> available
|
||||
describe('query aliases', () => {
|
||||
it('should resolve enabled to available', () => {
|
||||
expect(normalizeStatusForQuery('enabled')).toBe('available')
|
||||
})
|
||||
|
||||
it('should resolve Enabled (mixed case) to available', () => {
|
||||
expect(normalizeStatusForQuery('Enabled')).toBe('available')
|
||||
})
|
||||
})
|
||||
|
||||
// Non-aliased known values should pass through
|
||||
describe('non-aliased known values', () => {
|
||||
it.each([
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'disabled',
|
||||
'archived',
|
||||
])('should return %s as-is when not aliased', (status) => {
|
||||
expect(normalizeStatusForQuery(status)).toBe(status)
|
||||
})
|
||||
})
|
||||
|
||||
// URL alias flows through sanitize first, then query alias
|
||||
describe('combined alias resolution', () => {
|
||||
it('should resolve active through URL alias to available', () => {
|
||||
// active -> sanitizeStatusValue -> available -> no query alias for available -> available
|
||||
expect(normalizeStatusForQuery('active')).toBe('available')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
115
web/app/components/datasets/documents/status-item/hooks.spec.ts
Normal file
115
web/app/components/datasets/documents/status-item/hooks.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useIndexStatus } from './hooks'
|
||||
|
||||
describe('useIndexStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify the hook returns all expected status keys
|
||||
it('should return all expected status keys', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
|
||||
const expectedKeys = ['queuing', 'indexing', 'paused', 'error', 'available', 'enabled', 'disabled', 'archived']
|
||||
expect(Object.keys(result.current)).toEqual(expectedKeys)
|
||||
})
|
||||
|
||||
// Verify each status entry has the correct color
|
||||
describe('colors', () => {
|
||||
it('should return orange color for queuing', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.queuing.color).toBe('orange')
|
||||
})
|
||||
|
||||
it('should return blue color for indexing', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.indexing.color).toBe('blue')
|
||||
})
|
||||
|
||||
it('should return orange color for paused', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.paused.color).toBe('orange')
|
||||
})
|
||||
|
||||
it('should return red color for error', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.error.color).toBe('red')
|
||||
})
|
||||
|
||||
it('should return green color for available', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.available.color).toBe('green')
|
||||
})
|
||||
|
||||
it('should return green color for enabled', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.enabled.color).toBe('green')
|
||||
})
|
||||
|
||||
it('should return gray color for disabled', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.disabled.color).toBe('gray')
|
||||
})
|
||||
|
||||
it('should return gray color for archived', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.archived.color).toBe('gray')
|
||||
})
|
||||
})
|
||||
|
||||
// Verify each status entry has translated text (global mock returns ns.key format)
|
||||
describe('translated text', () => {
|
||||
it('should return translated text for queuing', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.queuing.text).toBe('datasetDocuments.list.status.queuing')
|
||||
})
|
||||
|
||||
it('should return translated text for indexing', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.indexing.text).toBe('datasetDocuments.list.status.indexing')
|
||||
})
|
||||
|
||||
it('should return translated text for paused', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.paused.text).toBe('datasetDocuments.list.status.paused')
|
||||
})
|
||||
|
||||
it('should return translated text for error', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.error.text).toBe('datasetDocuments.list.status.error')
|
||||
})
|
||||
|
||||
it('should return translated text for available', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.available.text).toBe('datasetDocuments.list.status.available')
|
||||
})
|
||||
|
||||
it('should return translated text for enabled', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.enabled.text).toBe('datasetDocuments.list.status.enabled')
|
||||
})
|
||||
|
||||
it('should return translated text for disabled', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.disabled.text).toBe('datasetDocuments.list.status.disabled')
|
||||
})
|
||||
|
||||
it('should return translated text for archived', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
expect(result.current.archived.text).toBe('datasetDocuments.list.status.archived')
|
||||
})
|
||||
})
|
||||
|
||||
// Verify each entry has both color and text properties
|
||||
it('should return objects with color and text properties for every status', () => {
|
||||
const { result } = renderHook(() => useIndexStatus())
|
||||
|
||||
for (const key of Object.keys(result.current) as Array<keyof typeof result.current>) {
|
||||
expect(result.current[key]).toHaveProperty('color')
|
||||
expect(result.current[key]).toHaveProperty('text')
|
||||
expect(typeof result.current[key].color).toBe('string')
|
||||
expect(typeof result.current[key].text).toBe('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import InfoPanel from './InfoPanel'
|
||||
|
||||
// Mock useDocLink from @/context/i18n
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
describe('InfoPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies the panel renders all expected content
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<InfoPanel />)
|
||||
expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the title text', () => {
|
||||
render(<InfoPanel />)
|
||||
expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the front content text', () => {
|
||||
render(<InfoPanel />)
|
||||
expect(screen.getByText(/connectDatasetIntro\.content\.front/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the content link', () => {
|
||||
render(<InfoPanel />)
|
||||
expect(screen.getByText(/connectDatasetIntro\.content\.link/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the end content text', () => {
|
||||
render(<InfoPanel />)
|
||||
expect(screen.getByText(/connectDatasetIntro\.content\.end/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the learn more link', () => {
|
||||
render(<InfoPanel />)
|
||||
expect(screen.getByText(/connectDatasetIntro\.learnMore/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the book icon', () => {
|
||||
const { container } = render(<InfoPanel />)
|
||||
const svgIcons = container.querySelectorAll('svg')
|
||||
expect(svgIcons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests links and their attributes
|
||||
describe('Links', () => {
|
||||
it('should have correct href for external knowledge API doc link', () => {
|
||||
render(<InfoPanel />)
|
||||
const docLink = screen.getByText(/connectDatasetIntro\.content\.link/)
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/external-knowledge-api')
|
||||
})
|
||||
|
||||
it('should have correct href for learn more link', () => {
|
||||
render(<InfoPanel />)
|
||||
const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/)
|
||||
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/connect-external-knowledge-base')
|
||||
})
|
||||
|
||||
it('should open links in new tab', () => {
|
||||
render(<InfoPanel />)
|
||||
const docLink = screen.getByText(/connectDatasetIntro\.content\.link/)
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/)
|
||||
expect(learnMoreLink).toHaveAttribute('target', '_blank')
|
||||
expect(learnMoreLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
// Styles: checks structural class names
|
||||
describe('Styles', () => {
|
||||
it('should have correct container width', () => {
|
||||
const { container } = render(<InfoPanel />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('w-[360px]')
|
||||
})
|
||||
|
||||
it('should have correct panel background', () => {
|
||||
const { container } = render(<InfoPanel />)
|
||||
const panel = container.querySelector('.bg-background-section')
|
||||
expect(panel).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import KnowledgeBaseInfo from './KnowledgeBaseInfo'
|
||||
|
||||
describe('KnowledgeBaseInfo', () => {
|
||||
const defaultProps = {
|
||||
name: '',
|
||||
description: '',
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies all form fields render
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/externalKnowledgeName/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the name label', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/externalKnowledgeName(?!Placeholder)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the description label', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/externalKnowledgeDescription(?!Placeholder)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name input with placeholder', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} />)
|
||||
const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/)
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description textarea with placeholder', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} />)
|
||||
const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests value display and onChange callbacks
|
||||
describe('Props', () => {
|
||||
it('should display name in the input', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} name="My Knowledge Base" />)
|
||||
const input = screen.getByDisplayValue('My Knowledge Base')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display description in the textarea', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} description="A description" />)
|
||||
const textarea = screen.getByDisplayValue('A description')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with name when name input changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />)
|
||||
const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'New Name' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ name: 'New Name' })
|
||||
})
|
||||
|
||||
it('should call onChange with description when textarea changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />)
|
||||
const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/)
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'New Description' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ description: 'New Description' })
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions: tests form interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should allow typing in name input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />)
|
||||
const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Typed Name' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith({ name: 'Typed Name' })
|
||||
})
|
||||
|
||||
it('should allow typing in description textarea', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />)
|
||||
const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/)
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'Typed Desc' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith({ description: 'Typed Desc' })
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests boundary values
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} name="" />)
|
||||
const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/)
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle undefined description', () => {
|
||||
render(<KnowledgeBaseInfo {...defaultProps} description={undefined} />)
|
||||
const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long name', () => {
|
||||
const longName = 'K'.repeat(500)
|
||||
render(<KnowledgeBaseInfo {...defaultProps} name={longName} />)
|
||||
const input = screen.getByDisplayValue(longName)
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long description', () => {
|
||||
const longDesc = 'D'.repeat(2000)
|
||||
render(<KnowledgeBaseInfo {...defaultProps} description={longDesc} />)
|
||||
const textarea = screen.getByDisplayValue(longDesc)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
const specialName = 'Test & "quotes" <angle>'
|
||||
render(<KnowledgeBaseInfo {...defaultProps} name={specialName} />)
|
||||
const input = screen.getByDisplayValue(specialName)
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply filled text color class when description has content', () => {
|
||||
const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="has content" />)
|
||||
const textarea = container.querySelector('textarea')
|
||||
expect(textarea).toHaveClass('text-components-input-text-filled')
|
||||
})
|
||||
|
||||
it('should apply placeholder text color class when description is empty', () => {
|
||||
const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="" />)
|
||||
const textarea = container.querySelector('textarea')
|
||||
expect(textarea).toHaveClass('text-components-input-text-placeholder')
|
||||
})
|
||||
})
|
||||
})
|
||||
186
web/app/components/datasets/extra-info/api-access/card.spec.tsx
Normal file
186
web/app/components/datasets/extra-info/api-access/card.spec.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Card from './card'
|
||||
|
||||
// Shared mock state for context selectors
|
||||
let mockDatasetId: string | undefined = 'dataset-123'
|
||||
let mockMutateDatasetRes: ReturnType<typeof vi.fn> = vi.fn()
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
dataset: { id: mockDatasetId },
|
||||
mutateDatasetRes: mockMutateDatasetRes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }),
|
||||
}))
|
||||
|
||||
const mockEnableApi = vi.fn()
|
||||
const mockDisableApi = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useEnableDatasetServiceApi: () => ({
|
||||
mutateAsync: mockEnableApi,
|
||||
}),
|
||||
useDisableDatasetServiceApi: () => ({
|
||||
mutateAsync: mockDisableApi,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets',
|
||||
}))
|
||||
|
||||
describe('Card (API Access)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDatasetId = 'dataset-123'
|
||||
mockMutateDatasetRes = vi.fn()
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
})
|
||||
|
||||
// Rendering: verifies enabled/disabled states render correctly
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when api is enabled', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without crashing when api is disabled', () => {
|
||||
render(<Card apiEnabled={false} />)
|
||||
expect(screen.getByText(/serviceApi\.disabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API access tip text', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/appMenus\.apiAccessTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API reference link', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
|
||||
})
|
||||
|
||||
it('should render API doc text in link', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
expect(screen.getByText(/apiInfo\.doc/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open API reference link in new tab', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests enabled/disabled visual states
|
||||
describe('Props', () => {
|
||||
it('should show green indicator text when enabled', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
const enabledText = screen.getByText(/serviceApi\.enabled/)
|
||||
expect(enabledText).toHaveClass('text-text-success')
|
||||
})
|
||||
|
||||
it('should show warning text when disabled', () => {
|
||||
render(<Card apiEnabled={false} />)
|
||||
const disabledText = screen.getByText(/serviceApi\.disabled/)
|
||||
expect(disabledText).toHaveClass('text-text-warning')
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions: tests toggle behavior
|
||||
describe('User Interactions', () => {
|
||||
it('should call enableDatasetServiceApi when toggling on', async () => {
|
||||
mockEnableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableApi).toHaveBeenCalledWith('dataset-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call disableDatasetServiceApi when toggling off', async () => {
|
||||
mockDisableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableApi).toHaveBeenCalledWith('dataset-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateDatasetRes on successful toggle', async () => {
|
||||
mockEnableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateDatasetRes).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call mutateDatasetRes when result is not success', async () => {
|
||||
mockEnableApi.mockResolvedValue({ result: 'fail' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableApi).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Switch disabled state
|
||||
describe('Switch State', () => {
|
||||
it('should disable switch when user is not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).toHaveAttribute('aria-checked', 'true')
|
||||
// Headless UI Switch uses CSS classes for disabled state, not the disabled attribute
|
||||
expect(switchButton).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
|
||||
it('should enable switch when user is workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
render(<Card apiEnabled={true} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests boundary scenarios
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined dataset id', async () => {
|
||||
mockDatasetId = undefined
|
||||
mockEnableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableApi).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
151
web/app/components/datasets/extra-info/service-api/card.spec.tsx
Normal file
151
web/app/components/datasets/extra-info/service-api/card.spec.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Card from './card'
|
||||
|
||||
vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets',
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
return render(ui, { wrapper: createWrapper() })
|
||||
}
|
||||
|
||||
describe('Card (Service API)', () => {
|
||||
const defaultProps = {
|
||||
apiBaseUrl: 'https://api.dify.ai/v1',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: verifies all key elements render
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render card title', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render enabled status', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render endpoint label', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the API base URL', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API key button', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.apiKey/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API reference button', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
expect(screen.getByText(/serviceApi\.card\.apiReference/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: tests different apiBaseUrl values
|
||||
describe('Props', () => {
|
||||
it('should display provided apiBaseUrl', () => {
|
||||
renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" />)
|
||||
expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show green indicator when apiBaseUrl is provided', () => {
|
||||
renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" />)
|
||||
// The Indicator component receives color="green" when apiBaseUrl is truthy
|
||||
const statusText = screen.getByText(/serviceApi\.enabled/)
|
||||
expect(statusText).toHaveClass('text-text-success')
|
||||
})
|
||||
|
||||
it('should show yellow indicator when apiBaseUrl is empty', () => {
|
||||
renderWithProviders(<Card apiBaseUrl="" />)
|
||||
// Still shows "enabled" text but indicator color differs
|
||||
expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions: tests button clicks and modal
|
||||
describe('User Interactions', () => {
|
||||
it('should open secret key modal when API key button is clicked', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
|
||||
const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button')
|
||||
fireEvent.click(apiKeyButton!)
|
||||
|
||||
// SecretKeyModal should appear with isShow=true
|
||||
// The modal renders when isSecretKeyModalVisible becomes true
|
||||
// We verify the modal is rendered in the DOM
|
||||
expect(screen.getByText(/serviceApi\.card\.apiKey/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API reference as a link', () => {
|
||||
renderWithProviders(<Card {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
// Styles: verifies container structure
|
||||
describe('Styles', () => {
|
||||
it('should have correct container width', () => {
|
||||
const { container } = renderWithProviders(<Card {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('w-[360px]')
|
||||
})
|
||||
|
||||
it('should have rounded corners', () => {
|
||||
const { container } = renderWithProviders(<Card {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests empty/long URLs
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty apiBaseUrl', () => {
|
||||
renderWithProviders(<Card apiBaseUrl="" />)
|
||||
// Should still render the structure
|
||||
expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long apiBaseUrl', () => {
|
||||
const longUrl = `https://api.dify.ai/${'path/'.repeat(50)}`
|
||||
renderWithProviders(<Card apiBaseUrl={longUrl} />)
|
||||
expect(screen.getByText(longUrl)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle apiBaseUrl with special characters', () => {
|
||||
const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar'
|
||||
renderWithProviders(<Card apiBaseUrl={specialUrl} />)
|
||||
expect(screen.getByText(specialUrl)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
552
web/app/components/datasets/formatted-text/index.spec.tsx
Normal file
552
web/app/components/datasets/formatted-text/index.spec.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SliceContainer, SliceContent, SliceDivider, SliceLabel } from './flavours/shared'
|
||||
import { FormattedText } from './formatted'
|
||||
|
||||
// Capture onOpenChange so tests can trigger open/close via React state
|
||||
let capturedOnOpenChange: ((open: boolean) => void) | null = null
|
||||
|
||||
vi.mock('@floating-ui/react', () => {
|
||||
return {
|
||||
autoUpdate: vi.fn(),
|
||||
flip: vi.fn(() => 'flip-middleware'),
|
||||
shift: vi.fn(() => 'shift-middleware'),
|
||||
offset: vi.fn(() => 'offset-middleware'),
|
||||
inline: vi.fn(() => 'inline-middleware'),
|
||||
useFloating: vi.fn(({ open, onOpenChange }: { open: boolean, onOpenChange: (v: boolean) => void }) => {
|
||||
capturedOnOpenChange = onOpenChange
|
||||
return {
|
||||
refs: {
|
||||
setReference: vi.fn(),
|
||||
setFloating: vi.fn(),
|
||||
},
|
||||
floatingStyles: { position: 'absolute' as const, top: 0, left: 0 },
|
||||
context: { open, onOpenChange },
|
||||
}
|
||||
}),
|
||||
useHover: vi.fn(() => ({})),
|
||||
useDismiss: vi.fn(() => ({})),
|
||||
useRole: vi.fn(() => ({})),
|
||||
useInteractions: vi.fn(() => ({
|
||||
getReferenceProps: vi.fn(() => ({})),
|
||||
getFloatingProps: vi.fn(() => ({})),
|
||||
})),
|
||||
FloatingFocusManager: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="floating-focus-manager">{children}</div>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
// Lazy import after mocks are set up
|
||||
const { EditSlice } = await import('./flavours/edit-slice')
|
||||
const { PreviewSlice } = await import('./flavours/preview-slice')
|
||||
|
||||
/** Helper: find the leaf span that contains the zero-width space (SliceDivider) */
|
||||
const findDividerSpan = (container: HTMLElement): HTMLSpanElement | undefined => {
|
||||
const spans = container.querySelectorAll('span')
|
||||
return Array.from(spans).find(
|
||||
s => s.children.length === 0 && s.textContent?.includes('\u200B'),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Tests for FormattedText - a paragraph wrapper with default leading-7 class
|
||||
describe('FormattedText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children when provided', () => {
|
||||
render(<FormattedText>Hello World</FormattedText>)
|
||||
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as a p element', () => {
|
||||
render(<FormattedText>content</FormattedText>)
|
||||
|
||||
expect(screen.getByText('content').tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should apply default leading-7 class', () => {
|
||||
render(<FormattedText>text</FormattedText>)
|
||||
|
||||
expect(screen.getByText('text')).toHaveClass('leading-7')
|
||||
})
|
||||
|
||||
it('should merge custom className with default class', () => {
|
||||
render(<FormattedText className="custom-class">text</FormattedText>)
|
||||
|
||||
const el = screen.getByText('text')
|
||||
expect(el).toHaveClass('leading-7')
|
||||
expect(el).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should pass rest props to the p element', () => {
|
||||
render(<FormattedText data-testid="formatted" id="my-id">text</FormattedText>)
|
||||
|
||||
const el = screen.getByTestId('formatted')
|
||||
expect(el).toHaveAttribute('id', 'my-id')
|
||||
})
|
||||
|
||||
it('should render nested elements as children', () => {
|
||||
render(
|
||||
<FormattedText>
|
||||
<span>nested</span>
|
||||
</FormattedText>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('nested')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty children without crashing', () => {
|
||||
const { container } = render(<FormattedText />)
|
||||
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for shared slice wrapper components
|
||||
describe('SliceContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render as a span element', () => {
|
||||
render(<SliceContainer data-testid="container" />)
|
||||
|
||||
expect(screen.getByTestId('container').tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('should apply default className', () => {
|
||||
render(<SliceContainer data-testid="container" />)
|
||||
|
||||
expect(screen.getByTestId('container')).toHaveClass('group', 'mr-1', 'select-none', 'align-bottom', 'text-sm')
|
||||
})
|
||||
|
||||
it('should merge custom className', () => {
|
||||
render(<SliceContainer data-testid="container" className="extra" />)
|
||||
|
||||
const el = screen.getByTestId('container')
|
||||
expect(el).toHaveClass('group')
|
||||
expect(el).toHaveClass('extra')
|
||||
})
|
||||
|
||||
it('should pass rest props to span', () => {
|
||||
render(<SliceContainer data-testid="container" id="slice-1" />)
|
||||
|
||||
expect(screen.getByTestId('container')).toHaveAttribute('id', 'slice-1')
|
||||
})
|
||||
|
||||
it('should have correct displayName', () => {
|
||||
expect(SliceContainer.displayName).toBe('SliceContainer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SliceLabel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
render(<SliceLabel>Label Text</SliceLabel>)
|
||||
|
||||
expect(screen.getByText('Label Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply default classes including uppercase and bg classes', () => {
|
||||
render(<SliceLabel data-testid="label">L</SliceLabel>)
|
||||
|
||||
const outer = screen.getByTestId('label')
|
||||
expect(outer).toHaveClass('uppercase')
|
||||
expect(outer).toHaveClass('px-1')
|
||||
})
|
||||
|
||||
it('should merge custom className', () => {
|
||||
render(<SliceLabel data-testid="label" className="custom">L</SliceLabel>)
|
||||
|
||||
expect(screen.getByTestId('label')).toHaveClass('custom')
|
||||
})
|
||||
|
||||
it('should apply labelInnerClassName to inner span', () => {
|
||||
render(<SliceLabel labelInnerClassName="inner-custom">L</SliceLabel>)
|
||||
|
||||
const inner = screen.getByText('L')
|
||||
expect(inner).toHaveClass('text-nowrap')
|
||||
expect(inner).toHaveClass('inner-custom')
|
||||
})
|
||||
|
||||
it('should have correct displayName', () => {
|
||||
expect(SliceLabel.displayName).toBe('SliceLabel')
|
||||
})
|
||||
|
||||
it('should pass rest props', () => {
|
||||
render(<SliceLabel data-testid="label" aria-label="chunk label">L</SliceLabel>)
|
||||
|
||||
expect(screen.getByTestId('label')).toHaveAttribute('aria-label', 'chunk label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SliceContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
render(<SliceContent>Some content</SliceContent>)
|
||||
|
||||
expect(screen.getByText('Some content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
render(<SliceContent data-testid="content">text</SliceContent>)
|
||||
|
||||
const el = screen.getByTestId('content')
|
||||
expect(el).toHaveClass('whitespace-pre-line', 'break-all', 'px-1', 'leading-7')
|
||||
})
|
||||
|
||||
it('should merge custom className', () => {
|
||||
render(<SliceContent data-testid="content" className="my-class">text</SliceContent>)
|
||||
|
||||
expect(screen.getByTestId('content')).toHaveClass('my-class')
|
||||
})
|
||||
|
||||
it('should have correct displayName', () => {
|
||||
expect(SliceContent.displayName).toBe('SliceContent')
|
||||
})
|
||||
|
||||
it('should pass rest props', () => {
|
||||
render(<SliceContent data-testid="content" title="hover">text</SliceContent>)
|
||||
|
||||
expect(screen.getByTestId('content')).toHaveAttribute('title', 'hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SliceDivider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render a span element', () => {
|
||||
render(<SliceDivider data-testid="divider" />)
|
||||
|
||||
expect(screen.getByTestId('divider').tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('should contain a zero-width space character', () => {
|
||||
render(<SliceDivider data-testid="divider" />)
|
||||
|
||||
expect(screen.getByTestId('divider').textContent).toContain('\u200B')
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
render(<SliceDivider data-testid="divider" />)
|
||||
|
||||
expect(screen.getByTestId('divider')).toHaveClass('px-[1px]', 'text-sm')
|
||||
})
|
||||
|
||||
it('should merge custom className', () => {
|
||||
render(<SliceDivider data-testid="divider" className="extra" />)
|
||||
|
||||
expect(screen.getByTestId('divider')).toHaveClass('extra')
|
||||
})
|
||||
|
||||
it('should have correct displayName', () => {
|
||||
expect(SliceDivider.displayName).toBe('SliceDivider')
|
||||
})
|
||||
|
||||
it('should pass rest props', () => {
|
||||
render(<SliceDivider data-testid="divider" id="d1" />)
|
||||
|
||||
expect(screen.getByTestId('divider')).toHaveAttribute('id', 'd1')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for EditSlice - floating delete button on hover
|
||||
describe('EditSlice', () => {
|
||||
const defaultProps = {
|
||||
label: 'S1',
|
||||
text: 'Sample text content',
|
||||
onDelete: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedOnOpenChange = null
|
||||
})
|
||||
|
||||
it('should render label and text', () => {
|
||||
render(<EditSlice {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('S1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sample text content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider by default when showDivider is true', () => {
|
||||
const { container } = render(<EditSlice {...defaultProps} />)
|
||||
|
||||
expect(findDividerSpan(container)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should hide divider when showDivider is false', () => {
|
||||
const { container } = render(<EditSlice {...defaultProps} showDivider={false} />)
|
||||
|
||||
expect(findDividerSpan(container)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not show delete button when floating is closed', () => {
|
||||
render(<EditSlice {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show delete button when onOpenChange triggers open', () => {
|
||||
render(<EditSlice {...defaultProps} />)
|
||||
|
||||
// Simulate floating-ui hover triggering open via the captured state setter
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
render(<EditSlice {...defaultProps} onDelete={onDelete} />)
|
||||
|
||||
// Open the floating UI
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
const deleteBtn = screen.getByRole('button')
|
||||
fireEvent.click(deleteBtn)
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should close floating after delete button is clicked', () => {
|
||||
render(<EditSlice {...defaultProps} />)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// setDelBtnShow(false) is called after onDelete
|
||||
expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className to SliceContainer', () => {
|
||||
render(<EditSlice {...defaultProps} data-testid="edit-slice" className="custom-slice" />)
|
||||
|
||||
expect(screen.getByTestId('edit-slice')).toHaveClass('custom-slice')
|
||||
})
|
||||
|
||||
it('should apply labelClassName to SliceLabel', () => {
|
||||
render(<EditSlice {...defaultProps} labelClassName="label-extra" />)
|
||||
|
||||
const labelEl = screen.getByText('S1').parentElement
|
||||
expect(labelEl).toHaveClass('label-extra')
|
||||
})
|
||||
|
||||
it('should apply contentClassName to SliceContent', () => {
|
||||
render(<EditSlice {...defaultProps} contentClassName="content-extra" />)
|
||||
|
||||
expect(screen.getByText('Sample text content')).toHaveClass('content-extra')
|
||||
})
|
||||
|
||||
it('should apply labelInnerClassName to SliceLabel inner span', () => {
|
||||
render(<EditSlice {...defaultProps} labelInnerClassName="inner-label" />)
|
||||
|
||||
expect(screen.getByText('S1')).toHaveClass('inner-label')
|
||||
})
|
||||
|
||||
it('should apply destructive styles when hovering on delete button container', async () => {
|
||||
render(<EditSlice {...defaultProps} />)
|
||||
|
||||
// Open floating
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
// Hover on the floating span to trigger destructive style
|
||||
const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement
|
||||
fireEvent.mouseEnter(floatingSpan)
|
||||
|
||||
await waitFor(() => {
|
||||
const labelEl = screen.getByText('S1').parentElement
|
||||
expect(labelEl).toHaveClass('!bg-state-destructive-solid')
|
||||
expect(labelEl).toHaveClass('!text-text-primary-on-surface')
|
||||
})
|
||||
|
||||
// Content should also get destructive style
|
||||
expect(screen.getByText('Sample text content')).toHaveClass('!bg-state-destructive-hover-alt')
|
||||
})
|
||||
|
||||
it('should remove destructive styles when mouse leaves delete button container', async () => {
|
||||
render(<EditSlice {...defaultProps} />)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement
|
||||
fireEvent.mouseEnter(floatingSpan)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('S1').parentElement).toHaveClass('!bg-state-destructive-solid')
|
||||
})
|
||||
|
||||
fireEvent.mouseLeave(floatingSpan)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('S1').parentElement).not.toHaveClass('!bg-state-destructive-solid')
|
||||
expect(screen.getByText('Sample text content')).not.toHaveClass('!bg-state-destructive-hover-alt')
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply destructive style to divider when hovering delete button', async () => {
|
||||
const { container } = render(<EditSlice {...defaultProps} />)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement
|
||||
fireEvent.mouseEnter(floatingSpan)
|
||||
|
||||
await waitFor(() => {
|
||||
const divider = findDividerSpan(container)
|
||||
expect(divider).toHaveClass('!bg-state-destructive-hover-alt')
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass rest props to SliceContainer', () => {
|
||||
render(<EditSlice {...defaultProps} data-testid="edit-slice" />)
|
||||
|
||||
expect(screen.getByTestId('edit-slice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop event propagation on delete click', () => {
|
||||
const parentClick = vi.fn()
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<EditSlice {...defaultProps} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for PreviewSlice - tooltip on hover
|
||||
describe('PreviewSlice', () => {
|
||||
const defaultProps = {
|
||||
label: 'P1',
|
||||
text: 'Preview text',
|
||||
tooltip: 'Tooltip content',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedOnOpenChange = null
|
||||
})
|
||||
|
||||
it('should render label and text', () => {
|
||||
render(<PreviewSlice {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('P1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preview text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show tooltip by default', () => {
|
||||
render(<PreviewSlice {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tooltip when onOpenChange triggers open', () => {
|
||||
render(<PreviewSlice {...defaultProps} />)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Tooltip content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tooltip when onOpenChange triggers close', () => {
|
||||
render(<PreviewSlice {...defaultProps} />)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
expect(screen.getByText('Tooltip content')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(false)
|
||||
})
|
||||
expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should always render a divider', () => {
|
||||
const { container } = render(<PreviewSlice {...defaultProps} />)
|
||||
|
||||
expect(findDividerSpan(container)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply custom className to SliceContainer', () => {
|
||||
render(<PreviewSlice {...defaultProps} data-testid="preview-slice" className="preview-custom" />)
|
||||
|
||||
expect(screen.getByTestId('preview-slice')).toHaveClass('preview-custom')
|
||||
})
|
||||
|
||||
it('should apply labelInnerClassName to the label inner span', () => {
|
||||
render(<PreviewSlice {...defaultProps} labelInnerClassName="label-inner" />)
|
||||
|
||||
expect(screen.getByText('P1')).toHaveClass('label-inner')
|
||||
})
|
||||
|
||||
it('should apply dividerClassName to SliceDivider', () => {
|
||||
const { container } = render(<PreviewSlice {...defaultProps} dividerClassName="divider-custom" />)
|
||||
|
||||
const divider = findDividerSpan(container)
|
||||
expect(divider).toHaveClass('divider-custom')
|
||||
})
|
||||
|
||||
it('should pass rest props to SliceContainer', () => {
|
||||
render(<PreviewSlice {...defaultProps} data-testid="preview-slice" />)
|
||||
|
||||
expect(screen.getByTestId('preview-slice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ReactNode tooltip content when open', () => {
|
||||
render(<PreviewSlice {...defaultProps} tooltip={<strong>Rich tooltip</strong>} />)
|
||||
|
||||
act(() => {
|
||||
capturedOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Rich tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ReactNode label', () => {
|
||||
render(<PreviewSlice {...defaultProps} label={<em>Emphasis</em>} />)
|
||||
|
||||
expect(screen.getByText('Emphasis')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,836 @@
|
||||
import type { ExternalKnowledgeBaseHitTesting, HitTestingChildChunk } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
|
||||
import ChildChunksItem from './child-chunks-item'
|
||||
import EmptyRecords from './empty-records'
|
||||
import Mask from './mask'
|
||||
import Textarea from './query-input/textarea'
|
||||
import ResultItemExternal from './result-item-external'
|
||||
import ResultItemFooter from './result-item-footer'
|
||||
import ResultItemMeta from './result-item-meta'
|
||||
import Score from './score'
|
||||
|
||||
let mockIsShowDetailModal = false
|
||||
const mockShowDetailModal = vi.fn(() => {
|
||||
mockIsShowDetailModal = true
|
||||
})
|
||||
const mockHideDetailModal = vi.fn(() => {
|
||||
mockIsShowDetailModal = false
|
||||
})
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (_initial: boolean) => {
|
||||
return [
|
||||
mockIsShowDetailModal,
|
||||
{
|
||||
setTrue: mockShowDetailModal,
|
||||
setFalse: mockHideDetailModal,
|
||||
toggle: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
]
|
||||
},
|
||||
}))
|
||||
|
||||
const createExternalPayload = (
|
||||
overrides: Partial<ExternalKnowledgeBaseHitTesting> = {},
|
||||
): ExternalKnowledgeBaseHitTesting => ({
|
||||
content: 'This is the chunk content for testing.',
|
||||
title: 'Test Document Title',
|
||||
score: 0.85,
|
||||
metadata: {
|
||||
'x-amz-bedrock-kb-source-uri': 's3://bucket/key',
|
||||
'x-amz-bedrock-kb-data-source-id': 'ds-123',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createChildChunkPayload = (
|
||||
overrides: Partial<HitTestingChildChunk> = {},
|
||||
): HitTestingChildChunk => ({
|
||||
id: 'chunk-1',
|
||||
content: 'Child chunk content here',
|
||||
position: 1,
|
||||
score: 0.75,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Score', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the score display component
|
||||
describe('Rendering', () => {
|
||||
it('should render score value with toFixed(2)', () => {
|
||||
// Arrange & Act
|
||||
render(<Score value={0.85} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0.85')).toBeInTheDocument()
|
||||
expect(screen.getByText('score')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render score progress bar with correct width', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={0.75} />)
|
||||
|
||||
// Assert
|
||||
const progressBar = container.querySelector('[style]')
|
||||
expect(progressBar).toHaveStyle({ width: '75%' })
|
||||
})
|
||||
|
||||
it('should render with besideChunkName styling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={0.5} besideChunkName />)
|
||||
|
||||
// Assert
|
||||
const root = container.firstElementChild
|
||||
expect(root?.className).toContain('h-[20.5px]')
|
||||
expect(root?.className).toContain('border-l-0')
|
||||
})
|
||||
|
||||
it('should render with default styling when besideChunkName is false', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={0.5} />)
|
||||
|
||||
// Assert
|
||||
const root = container.firstElementChild
|
||||
expect(root?.className).toContain('h-[20px]')
|
||||
expect(root?.className).toContain('rounded-md')
|
||||
})
|
||||
|
||||
it('should remove right border when value is exactly 1', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={1} />)
|
||||
|
||||
// Assert
|
||||
const progressBar = container.querySelector('[style]')
|
||||
expect(progressBar?.className).toContain('border-r-0')
|
||||
expect(progressBar).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('should show right border when value is less than 1', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={0.5} />)
|
||||
|
||||
// Assert
|
||||
const progressBar = container.querySelector('[style]')
|
||||
expect(progressBar?.className).not.toContain('border-r-0')
|
||||
})
|
||||
})
|
||||
|
||||
// Null return tests for edge cases
|
||||
describe('Returns null', () => {
|
||||
it('should return null when value is null', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={null} />)
|
||||
|
||||
// Assert
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when value is 0', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={0} />)
|
||||
|
||||
// Assert
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when value is NaN', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Score value={Number.NaN} />)
|
||||
|
||||
// Assert
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case tests
|
||||
describe('Edge Cases', () => {
|
||||
it('should render very small score values', () => {
|
||||
// Arrange & Act
|
||||
render(<Score value={0.01} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0.01')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render score with many decimals truncated to 2', () => {
|
||||
// Arrange & Act
|
||||
render(<Score value={0.123456} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0.12')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Mask Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Mask', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the gradient overlay component
|
||||
describe('Rendering', () => {
|
||||
it('should render a gradient overlay div', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Mask />)
|
||||
|
||||
// Assert
|
||||
const div = container.firstElementChild
|
||||
expect(div).toBeInTheDocument()
|
||||
expect(div?.className).toContain('h-12')
|
||||
expect(div?.className).toContain('bg-gradient-to-b')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Mask className="custom-mask" />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild?.className).toContain('custom-mask')
|
||||
})
|
||||
|
||||
it('should render without custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Mask />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EmptyRecords', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the empty state component
|
||||
describe('Rendering', () => {
|
||||
it('should render the "no recent" tip text', () => {
|
||||
// Arrange & Act
|
||||
render(<EmptyRecords />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the history icon', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<EmptyRecords />)
|
||||
|
||||
// Assert
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render inside a styled container', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<EmptyRecords />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper?.className).toContain('rounded-2xl')
|
||||
expect(wrapper?.className).toContain('bg-workflow-process-bg')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Textarea Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Textarea', () => {
|
||||
const mockHandleTextChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the textarea with character count
|
||||
describe('Rendering', () => {
|
||||
it('should render a textarea element', () => {
|
||||
// Arrange & Act
|
||||
render(<Textarea text="" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display the current text', () => {
|
||||
// Arrange & Act
|
||||
render(<Textarea text="Hello world" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Hello world')
|
||||
})
|
||||
|
||||
it('should show character count', () => {
|
||||
// Arrange & Act
|
||||
render(<Textarea text="Hello" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('5/200')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 0/200 for empty text', () => {
|
||||
// Arrange & Act
|
||||
render(<Textarea text="" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0/200')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder text', () => {
|
||||
// Arrange & Act
|
||||
render(<Textarea text="" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
// Warning state tests for exceeding character limit
|
||||
describe('Warning state (>200 chars)', () => {
|
||||
it('should apply warning border when text exceeds 200 characters', () => {
|
||||
// Arrange
|
||||
const longText = 'A'.repeat(201)
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<Textarea text={longText} handleTextChange={mockHandleTextChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper?.className).toContain('border-state-destructive-active')
|
||||
})
|
||||
|
||||
it('should not apply warning border when text is at 200 characters', () => {
|
||||
// Arrange
|
||||
const text200 = 'A'.repeat(200)
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<Textarea text={text200} handleTextChange={mockHandleTextChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper?.className).not.toContain('border-state-destructive-active')
|
||||
})
|
||||
|
||||
it('should not apply warning border when text is under 200 characters', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Textarea text="Short text" handleTextChange={mockHandleTextChange} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper?.className).not.toContain('border-state-destructive-active')
|
||||
})
|
||||
|
||||
it('should show warning count with red styling when over 200 chars', () => {
|
||||
// Arrange
|
||||
const longText = 'B'.repeat(250)
|
||||
|
||||
// Act
|
||||
render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
const countElement = screen.getByText('250/200')
|
||||
expect(countElement.className).toContain('text-util-colors-red-red-600')
|
||||
})
|
||||
|
||||
it('should show normal count styling when at or under 200 chars', () => {
|
||||
// Arrange & Act
|
||||
render(<Textarea text="Short" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Assert
|
||||
const countElement = screen.getByText('5/200')
|
||||
expect(countElement.className).toContain('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should show red corner icon when over 200 chars', () => {
|
||||
// Arrange
|
||||
const longText = 'C'.repeat(201)
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<Textarea text={longText} handleTextChange={mockHandleTextChange} />,
|
||||
)
|
||||
|
||||
// Assert - Corner icon should have red class
|
||||
const cornerWrapper = container.querySelector('.right-0.top-0')
|
||||
const cornerSvg = cornerWrapper?.querySelector('svg')
|
||||
expect(cornerSvg?.className.baseVal || cornerSvg?.getAttribute('class')).toContain('text-util-colors-red-red-100')
|
||||
})
|
||||
})
|
||||
|
||||
// User interaction tests
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleTextChange when text is entered', () => {
|
||||
// Arrange
|
||||
render(<Textarea text="" handleTextChange={mockHandleTextChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'New text' },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(mockHandleTextChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ResultItemFooter Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ResultItemFooter', () => {
|
||||
const mockShowDetailModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the result item footer
|
||||
describe('Rendering', () => {
|
||||
it('should render the document title', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ResultItemFooter
|
||||
docType={FileAppearanceTypeEnum.document}
|
||||
docTitle="My Document.pdf"
|
||||
showDetailModal={mockShowDetailModal}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('My Document.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the "open" button text', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ResultItemFooter
|
||||
docType={FileAppearanceTypeEnum.pdf}
|
||||
docTitle="File.pdf"
|
||||
showDetailModal={mockShowDetailModal}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/open/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the file icon', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ResultItemFooter
|
||||
docType={FileAppearanceTypeEnum.document}
|
||||
docTitle="File.txt"
|
||||
showDetailModal={mockShowDetailModal}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interaction tests
|
||||
describe('User Interactions', () => {
|
||||
it('should call showDetailModal when open button is clicked', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<ResultItemFooter
|
||||
docType={FileAppearanceTypeEnum.document}
|
||||
docTitle="Doc"
|
||||
showDetailModal={mockShowDetailModal}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const openButton = screen.getByText(/open/i).closest('.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(openButton)
|
||||
|
||||
// Assert
|
||||
expect(mockShowDetailModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ResultItemMeta Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ResultItemMeta', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the result item meta component
|
||||
describe('Rendering', () => {
|
||||
it('should render the segment index tag with prefix and position', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ResultItemMeta
|
||||
labelPrefix="Chunk"
|
||||
positionId={3}
|
||||
wordCount={150}
|
||||
score={0.9}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-03')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the word count', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ResultItemMeta
|
||||
labelPrefix="Chunk"
|
||||
positionId={1}
|
||||
wordCount={250}
|
||||
score={0.8}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/250/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/characters/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the score component', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ResultItemMeta
|
||||
labelPrefix="Chunk"
|
||||
positionId={1}
|
||||
wordCount={100}
|
||||
score={0.75}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0.75')).toBeInTheDocument()
|
||||
expect(screen.getByText('score')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ResultItemMeta
|
||||
className="custom-meta"
|
||||
labelPrefix="Chunk"
|
||||
positionId={1}
|
||||
wordCount={100}
|
||||
score={0.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstElementChild?.className).toContain('custom-meta')
|
||||
})
|
||||
|
||||
it('should render dot separator', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ResultItemMeta
|
||||
labelPrefix="Chunk"
|
||||
positionId={1}
|
||||
wordCount={100}
|
||||
score={0.5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ChildChunksItem Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ChildChunksItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for child chunk items
|
||||
describe('Rendering', () => {
|
||||
it('should render the position label', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload({ position: 3 })
|
||||
|
||||
// Act
|
||||
render(<ChildChunksItem payload={payload} isShowAll={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/C-/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the score component', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload({ score: 0.88 })
|
||||
|
||||
// Act
|
||||
render(<ChildChunksItem payload={payload} isShowAll={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0.88')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the content text', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload({ content: 'Sample chunk text' })
|
||||
|
||||
// Act
|
||||
render(<ChildChunksItem payload={payload} isShowAll={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Sample chunk text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with besideChunkName styling on Score', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload({ score: 0.6 })
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChildChunksItem payload={payload} isShowAll={false} />,
|
||||
)
|
||||
|
||||
// Assert - Score with besideChunkName has h-[20.5px] and border-l-0
|
||||
const scoreEl = container.querySelector('[class*="h-\\[20\\.5px\\]"]')
|
||||
expect(scoreEl).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Line clamping behavior tests
|
||||
describe('Line Clamping', () => {
|
||||
it('should apply line-clamp-2 when isShowAll is false', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload()
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChildChunksItem payload={payload} isShowAll={false} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const root = container.firstElementChild
|
||||
expect(root?.className).toContain('line-clamp-2')
|
||||
})
|
||||
|
||||
it('should not apply line-clamp-2 when isShowAll is true', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload()
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChildChunksItem payload={payload} isShowAll={true} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const root = container.firstElementChild
|
||||
expect(root?.className).not.toContain('line-clamp-2')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case tests
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with score 0 (Score returns null)', () => {
|
||||
// Arrange
|
||||
const payload = createChildChunkPayload({ score: 0 })
|
||||
|
||||
// Act
|
||||
render(<ChildChunksItem payload={payload} isShowAll={false} />)
|
||||
|
||||
// Assert - content still renders, score returns null
|
||||
expect(screen.getByText('Child chunk content here')).toBeInTheDocument()
|
||||
expect(screen.queryByText('score')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ResultItemExternal Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ResultItemExternal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsShowDetailModal = false
|
||||
})
|
||||
|
||||
// Rendering tests for the external result item card
|
||||
describe('Rendering', () => {
|
||||
it('should render the content text', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ content: 'External result content' })
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('External result content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the meta info with position and score', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ score: 0.92 })
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={5} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-05')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.92')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the footer with document title', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ title: 'Knowledge Base Doc' })
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Knowledge Base Doc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the word count from content length', () => {
|
||||
// Arrange
|
||||
const content = 'Hello World' // 11 chars
|
||||
const payload = createExternalPayload({ content })
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/11/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Detail modal tests
|
||||
describe('Detail Modal', () => {
|
||||
it('should not render modal by default', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload()
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/chunkDetail/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call showDetailModal when card is clicked', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload()
|
||||
mockIsShowDetailModal = false
|
||||
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Act - click the card to open modal
|
||||
const card = screen.getByText(payload.content).closest('.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(card)
|
||||
|
||||
// Assert - showDetailModal (setTrue) was invoked
|
||||
expect(mockShowDetailModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render modal content when isShowDetailModal is true', () => {
|
||||
// Arrange - modal is already open
|
||||
const payload = createExternalPayload()
|
||||
mockIsShowDetailModal = true
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert - modal title should appear
|
||||
expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render full content in the modal', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ content: 'Full modal content text' })
|
||||
mockIsShowDetailModal = true
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert - content appears both in card and modal
|
||||
const contentElements = screen.getAllByText('Full modal content text')
|
||||
expect(contentElements.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should render meta info in the modal', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ score: 0.77 })
|
||||
mockIsShowDetailModal = true
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={3} />)
|
||||
|
||||
// Assert - meta appears in both card and modal
|
||||
const chunkTags = screen.getAllByText('Chunk-03')
|
||||
expect(chunkTags.length).toBe(2)
|
||||
const scores = screen.getAllByText('0.77')
|
||||
expect(scores.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case tests
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty content', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ content: '' })
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert - component still renders
|
||||
expect(screen.getByText('Test Document Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with score of 0 (Score returns null)', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload({ score: 0 })
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={1} />)
|
||||
|
||||
// Assert - no score displayed
|
||||
expect(screen.queryByText('score')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large positionId values', () => {
|
||||
// Arrange
|
||||
const payload = createExternalPayload()
|
||||
|
||||
// Act
|
||||
render(<ResultItemExternal payload={payload} positionId={999} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-999')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
|
||||
import { extensionToFileType } from './extension-to-file-type'
|
||||
|
||||
describe('extensionToFileType', () => {
|
||||
// PDF extension
|
||||
describe('pdf', () => {
|
||||
it('should return pdf type when extension is pdf', () => {
|
||||
expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf)
|
||||
})
|
||||
})
|
||||
|
||||
// Word extensions
|
||||
describe('word', () => {
|
||||
it('should return word type when extension is doc', () => {
|
||||
expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word)
|
||||
})
|
||||
|
||||
it('should return word type when extension is docx', () => {
|
||||
expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word)
|
||||
})
|
||||
})
|
||||
|
||||
// Markdown extensions
|
||||
describe('markdown', () => {
|
||||
it('should return markdown type when extension is md', () => {
|
||||
expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown)
|
||||
})
|
||||
|
||||
it('should return markdown type when extension is mdx', () => {
|
||||
expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown)
|
||||
})
|
||||
|
||||
it('should return markdown type when extension is markdown', () => {
|
||||
expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown)
|
||||
})
|
||||
})
|
||||
|
||||
// Excel / CSV extensions
|
||||
describe('excel', () => {
|
||||
it('should return excel type when extension is csv', () => {
|
||||
expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel)
|
||||
})
|
||||
|
||||
it('should return excel type when extension is xls', () => {
|
||||
expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel)
|
||||
})
|
||||
|
||||
it('should return excel type when extension is xlsx', () => {
|
||||
expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel)
|
||||
})
|
||||
})
|
||||
|
||||
// Document extensions
|
||||
describe('document', () => {
|
||||
it('should return document type when extension is txt', () => {
|
||||
expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should return document type when extension is epub', () => {
|
||||
expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should return document type when extension is html', () => {
|
||||
expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should return document type when extension is htm', () => {
|
||||
expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should return document type when extension is xml', () => {
|
||||
expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
})
|
||||
|
||||
// PPT extensions
|
||||
describe('ppt', () => {
|
||||
it('should return ppt type when extension is ppt', () => {
|
||||
expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt)
|
||||
})
|
||||
|
||||
it('should return ppt type when extension is pptx', () => {
|
||||
expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt)
|
||||
})
|
||||
})
|
||||
|
||||
// Default / unknown extensions
|
||||
describe('custom (default)', () => {
|
||||
it('should return custom type when extension is empty string', () => {
|
||||
expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
|
||||
it('should return custom type when extension is unknown', () => {
|
||||
expect(extensionToFileType('zip')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
|
||||
it('should return custom type when extension is uppercase (case-sensitive match)', () => {
|
||||
expect(extensionToFileType('PDF')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
|
||||
it('should return custom type when extension is mixed case', () => {
|
||||
expect(extensionToFileType('Docx')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
|
||||
it('should return custom type when extension has leading dot', () => {
|
||||
expect(extensionToFileType('.pdf')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
|
||||
it('should return custom type when extension has whitespace', () => {
|
||||
expect(extensionToFileType(' pdf ')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
|
||||
it('should return custom type for image-like extensions', () => {
|
||||
expect(extensionToFileType('png')).toBe(FileAppearanceTypeEnum.custom)
|
||||
expect(extensionToFileType('jpg')).toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,254 +3,332 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import DatasetCard from './index'
|
||||
import DatasetCardFooter from './components/dataset-card-footer'
|
||||
import Description from './components/description'
|
||||
import OperationItem from './operation-item'
|
||||
import Operations from './operations'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock ahooks useHover
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
// Mock the useDatasetCardState hook
|
||||
vi.mock('./hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the RenameDatasetModal
|
||||
vi.mock('../../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
// Mock external hooks only
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString()
|
||||
return `${date.toLocaleDateString()}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCard', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
// Factory function for DataSet mock data
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
total_available_documents: 10,
|
||||
runtime_mode: 'general',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('DatasetCard Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
// Integration tests for Description component
|
||||
describe('Description', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render description text from dataset', () => {
|
||||
const dataset = createMockDataset({ description: 'My knowledge base' })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText('My knowledge base')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set title attribute to description', () => {
|
||||
const dataset = createMockDataset({ description: 'Hover text' })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByTitle('Hover text')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render dataset name', () => {
|
||||
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
|
||||
describe('Props', () => {
|
||||
it('should apply opacity-30 when embedding_available is false', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not apply opacity-30 when embedding_available is true', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).not.toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render dataset description', () => {
|
||||
const dataset = createMockDataset({ description: 'Custom Description' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Description')).toBeInTheDocument()
|
||||
})
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle('')
|
||||
expect(descDiv).toBeInTheDocument()
|
||||
expect(descDiv).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should render document count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
it('should handle long description', () => {
|
||||
const longDesc = 'X'.repeat(500)
|
||||
const dataset = createMockDataset({ description: longDesc })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(longDesc)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should handle external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
// Integration tests for DatasetCardFooter component
|
||||
describe('DatasetCardFooter', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render document count', () => {
|
||||
const dataset = createMockDataset({ document_count: 15, total_available_documents: 15 })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count for non-external provider', () => {
|
||||
const dataset = createMockDataset({ app_count: 7, provider: 'vendor' })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render update time', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText(/updated/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle rag_pipeline runtime mode', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
describe('Props', () => {
|
||||
it('should show partial count when total_available_documents < document_count', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 20,
|
||||
total_available_documents: 12,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('12 / 20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show single count when all documents are available', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 20,
|
||||
total_available_documents: 20,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show app count when provider is external', () => {
|
||||
const dataset = createMockDataset({ provider: 'external', app_count: 99 })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.queryByText('99')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have opacity when embedding_available is false', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined total_available_documents', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 10,
|
||||
total_available_documents: undefined,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// total_available_documents defaults to 0, which is < 10
|
||||
expect(screen.getByText('0 / 10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero document count', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 0,
|
||||
total_available_documents: 0,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 100000,
|
||||
total_available_documents: 100000,
|
||||
app_count: 50000,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('100000')).toBeInTheDocument()
|
||||
expect(screen.getByText('50000')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to documents page on click for regular dataset', () => {
|
||||
const dataset = createMockDataset({ provider: 'vendor' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
// Integration tests for OperationItem component
|
||||
describe('OperationItem', () => {
|
||||
const MockIcon = ({ className }: { className?: string }) => (
|
||||
<svg data-testid="mock-icon" className={className} />
|
||||
)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
|
||||
describe('Rendering', () => {
|
||||
it('should render icon and name', () => {
|
||||
render(<OperationItem Icon={MockIcon as never} name="Edit" />)
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to hitTesting page on click for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OperationItem Icon={MockIcon as never} name="Delete" handleClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
const item = screen.getByText('Delete').closest('div')
|
||||
fireEvent.click(item!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
|
||||
})
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should navigate to pipeline page when pipeline is unpublished', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
it('should prevent default and stop propagation on click', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OperationItem Icon={MockIcon as never} name="Action" handleClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
const item = screen.getByText('Action').closest('div')
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
|
||||
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
|
||||
})
|
||||
})
|
||||
item!.dispatchEvent(event)
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('.group')
|
||||
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have data-disable-nprogress attribute', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle dataset without description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle embedding not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<DatasetCard dataset={createMockDataset()} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Area Click', () => {
|
||||
it('should stop propagation and prevent default when tag area is clicked', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Find tag area element (it's inside the card)
|
||||
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
|
||||
if (tagAreaWrapper) {
|
||||
const stopPropagationSpy = vi.fn()
|
||||
const preventDefaultSpy = vi.fn()
|
||||
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true })
|
||||
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
|
||||
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
|
||||
|
||||
tagAreaWrapper.dispatchEvent(clickEvent)
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
}
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not navigate when clicking on tag area', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
describe('Edge Cases', () => {
|
||||
it('should not throw when handleClick is undefined', () => {
|
||||
render(<OperationItem Icon={MockIcon as never} name="No handler" />)
|
||||
const item = screen.getByText('No handler').closest('div')
|
||||
expect(() => {
|
||||
fireEvent.click(item!)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// Click on tag area should not trigger card navigation
|
||||
const tagArea = document.querySelector('[class*="px-3"]')
|
||||
if (tagArea) {
|
||||
fireEvent.click(tagArea)
|
||||
// mockPush should NOT be called when clicking tag area
|
||||
// (stopPropagation prevents it from reaching the card click handler)
|
||||
}
|
||||
it('should handle empty name', () => {
|
||||
render(<OperationItem Icon={MockIcon as never} name="" />)
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Integration tests for Operations component
|
||||
describe('Operations', () => {
|
||||
const defaultProps = {
|
||||
showDelete: true,
|
||||
showExportPipeline: true,
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should always render edit operation', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render export pipeline when showExportPipeline is true', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={true} />)
|
||||
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render export pipeline when showExportPipeline is false', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={false} />)
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete when showDelete is true', () => {
|
||||
render(<Operations {...defaultProps} showDelete={true} />)
|
||||
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render delete when showDelete is false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} />)
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call openRenameModal when edit is clicked', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
const editItem = screen.getByText(/operation\.edit/).closest('div')
|
||||
fireEvent.click(editItem!)
|
||||
|
||||
expect(openRenameModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleExportPipeline when export is clicked', () => {
|
||||
const handleExportPipeline = vi.fn()
|
||||
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
|
||||
|
||||
const exportItem = screen.getByText(/exportPipeline/).closest('div')
|
||||
fireEvent.click(exportItem!)
|
||||
|
||||
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call detectIsUsedByApp when delete is clicked', () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
|
||||
|
||||
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
|
||||
fireEvent.click(deleteItem!)
|
||||
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render only edit when both showDelete and showExportPipeline are false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider before delete section when showDelete is true', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDelete is false', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,76 +1,134 @@
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CreateAppCard from './index'
|
||||
import Option from './option'
|
||||
|
||||
describe('CreateAppCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3)
|
||||
describe('New Dataset Card Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Integration tests for Option component
|
||||
describe('Option', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render a link with text and icon', () => {
|
||||
render(<Option Icon={RiAddLine} text="Create" href="/create" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(screen.getByText('Create')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon with correct sizing class', () => {
|
||||
const { container } = render(<Option Icon={RiAddLine} text="Test" href="/test" />)
|
||||
const icon = container.querySelector('.h-4.w-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render create dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
describe('Props', () => {
|
||||
it('should set correct href on the link', () => {
|
||||
render(<Option Icon={RiAddLine} text="Go" href="/datasets/create" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/datasets/create')
|
||||
})
|
||||
|
||||
it('should render different text based on props', () => {
|
||||
render(<Option Icon={RiAddLine} text="Custom Text" href="/path" />)
|
||||
expect(screen.getByText('Custom Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different href based on props', () => {
|
||||
render(<Option Icon={RiAddLine} text="Link" href="/custom-path" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/custom-path')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render create from pipeline option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
|
||||
describe('Styles', () => {
|
||||
it('should have correct link styling', () => {
|
||||
render(<Option Icon={RiAddLine} text="Styled" href="/style" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
|
||||
})
|
||||
|
||||
it('should have text span with correct styling', () => {
|
||||
render(<Option Icon={RiAddLine} text="Text Style" href="/s" />)
|
||||
const textSpan = screen.getByText('Text Style')
|
||||
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render connect dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
render(<Option Icon={RiAddLine} text="" href="/empty" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long text', () => {
|
||||
const longText = 'Z'.repeat(200)
|
||||
render(<Option Icon={RiAddLine} text={longText} href="/long" />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should have correct displayName', () => {
|
||||
expect(CreateAppCard.displayName).toBe('CreateAppCard')
|
||||
})
|
||||
})
|
||||
// Integration tests for CreateAppCard component
|
||||
describe('CreateAppCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard />)
|
||||
// All 3 options should be visible
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(3)
|
||||
})
|
||||
|
||||
describe('Links', () => {
|
||||
it('should have correct href for create dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[0]).toHaveAttribute('href', '/datasets/create')
|
||||
it('should render the create dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the create from pipeline option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the connect dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have correct href for create from pipeline', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
|
||||
describe('Props', () => {
|
||||
it('should have correct href for create dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
const createLink = links.find(link => link.getAttribute('href') === '/datasets/create')
|
||||
expect(createLink).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct href for create from pipeline', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
const pipelineLink = links.find(link => link.getAttribute('href') === '/datasets/create-from-pipeline')
|
||||
expect(pipelineLink).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct href for connect dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
const connectLink = links.find(link => link.getAttribute('href') === '/datasets/connect')
|
||||
expect(connectLink).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have correct href for connect dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have border separator for connect option', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
|
||||
expect(borderDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render three icons for three options', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
// Each option has an icon
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThanOrEqual(3)
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
176
web/app/components/datasets/preview/container.spec.tsx
Normal file
176
web/app/components/datasets/preview/container.spec.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PreviewContainer from './container'
|
||||
|
||||
// Tests for PreviewContainer - a layout wrapper with header and scrollable main area
|
||||
describe('PreviewContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render header content in a header element', () => {
|
||||
render(<PreviewContainer header={<span>Header Title</span>}>Body</PreviewContainer>)
|
||||
|
||||
expect(screen.getByText('Header Title')).toBeInTheDocument()
|
||||
const headerEl = screen.getByText('Header Title').closest('header')
|
||||
expect(headerEl).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in a main element', () => {
|
||||
render(<PreviewContainer header="Header">Main content</PreviewContainer>)
|
||||
|
||||
const mainEl = screen.getByRole('main')
|
||||
expect(mainEl).toHaveTextContent('Main content')
|
||||
})
|
||||
|
||||
it('should render both header and children simultaneously', () => {
|
||||
render(
|
||||
<PreviewContainer header={<h2>My Header</h2>}>
|
||||
<p>Body paragraph</p>
|
||||
</PreviewContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('My Header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Body paragraph')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without children', () => {
|
||||
render(<PreviewContainer header="Header" />)
|
||||
|
||||
expect(screen.getByRole('main')).toBeInTheDocument()
|
||||
expect(screen.getByRole('main').childElementCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should apply className to the outer wrapper div', () => {
|
||||
const { container } = render(
|
||||
<PreviewContainer header="Header" className="outer-class">Content</PreviewContainer>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('outer-class')
|
||||
})
|
||||
|
||||
it('should apply mainClassName to the main element', () => {
|
||||
render(
|
||||
<PreviewContainer header="Header" mainClassName="custom-main">Content</PreviewContainer>,
|
||||
)
|
||||
|
||||
const mainEl = screen.getByRole('main')
|
||||
expect(mainEl).toHaveClass('custom-main')
|
||||
// Default classes should still be present
|
||||
expect(mainEl).toHaveClass('w-full', 'grow', 'overflow-y-auto', 'px-6', 'py-5')
|
||||
})
|
||||
|
||||
it('should forward ref to the inner container div', () => {
|
||||
const ref = vi.fn()
|
||||
render(
|
||||
<PreviewContainer header="Header" ref={ref}>Content</PreviewContainer>,
|
||||
)
|
||||
|
||||
expect(ref).toHaveBeenCalled()
|
||||
const refArg = ref.mock.calls[0][0]
|
||||
expect(refArg).toBeInstanceOf(HTMLDivElement)
|
||||
})
|
||||
|
||||
it('should pass rest props to the inner container div', () => {
|
||||
render(
|
||||
<PreviewContainer header="Header" data-testid="inner-container" id="container-1">
|
||||
Content
|
||||
</PreviewContainer>,
|
||||
)
|
||||
|
||||
const inner = screen.getByTestId('inner-container')
|
||||
expect(inner).toHaveAttribute('id', 'container-1')
|
||||
})
|
||||
|
||||
it('should render ReactNode as header', () => {
|
||||
render(
|
||||
<PreviewContainer header={<div data-testid="complex-header"><span>Complex</span></div>}>
|
||||
Content
|
||||
</PreviewContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('complex-header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Complex')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Layout structure tests
|
||||
describe('Layout Structure', () => {
|
||||
it('should have header with border-b styling', () => {
|
||||
render(<PreviewContainer header="Header">Content</PreviewContainer>)
|
||||
|
||||
const headerEl = screen.getByText('Header').closest('header')
|
||||
expect(headerEl).toHaveClass('border-b', 'border-divider-subtle')
|
||||
})
|
||||
|
||||
it('should have inner div with flex column layout', () => {
|
||||
render(
|
||||
<PreviewContainer header="Header" data-testid="inner">Content</PreviewContainer>,
|
||||
)
|
||||
|
||||
const inner = screen.getByTestId('inner')
|
||||
expect(inner).toHaveClass('flex', 'h-full', 'w-full', 'flex-col')
|
||||
})
|
||||
|
||||
it('should have main with overflow-y-auto for scrolling', () => {
|
||||
render(<PreviewContainer header="Header">Content</PreviewContainer>)
|
||||
|
||||
expect(screen.getByRole('main')).toHaveClass('overflow-y-auto')
|
||||
})
|
||||
})
|
||||
|
||||
// DisplayName test
|
||||
describe('DisplayName', () => {
|
||||
it('should have correct displayName', () => {
|
||||
expect(PreviewContainer.displayName).toBe('PreviewContainer')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty string header', () => {
|
||||
render(<PreviewContainer header="">Content</PreviewContainer>)
|
||||
|
||||
const headerEl = screen.getByRole('banner')
|
||||
expect(headerEl).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with null children', () => {
|
||||
render(<PreviewContainer header="Header">{null}</PreviewContainer>)
|
||||
|
||||
expect(screen.getByRole('main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiple children', () => {
|
||||
render(
|
||||
<PreviewContainer header="Header">
|
||||
<div>Child 1</div>
|
||||
<div>Child 2</div>
|
||||
<div>Child 3</div>
|
||||
</PreviewContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not crash on re-render with different props', () => {
|
||||
const { rerender } = render(
|
||||
<PreviewContainer header="First" className="a">Content A</PreviewContainer>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<PreviewContainer header="Second" className="b" mainClassName="new-main">Content B</PreviewContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
145
web/app/components/datasets/preview/header.spec.tsx
Normal file
145
web/app/components/datasets/preview/header.spec.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PreviewHeader } from './header'
|
||||
|
||||
// Tests for PreviewHeader - displays a title and optional children
|
||||
describe('PreviewHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render the title text', () => {
|
||||
render(<PreviewHeader title="Preview Title" />)
|
||||
|
||||
expect(screen.getByText('Preview Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children below the title', () => {
|
||||
render(
|
||||
<PreviewHeader title="Title">
|
||||
<span>Child content</span>
|
||||
</PreviewHeader>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without children', () => {
|
||||
const { container } = render(<PreviewHeader title="Solo Title" />)
|
||||
|
||||
expect(container.firstElementChild).toBeInTheDocument()
|
||||
expect(screen.getByText('Solo Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title in an inner div with uppercase styling', () => {
|
||||
render(<PreviewHeader title="Styled Title" />)
|
||||
|
||||
const titleEl = screen.getByText('Styled Title')
|
||||
expect(titleEl).toHaveClass('uppercase', 'mb-1', 'px-1', 'text-text-accent')
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to outer div', () => {
|
||||
render(<PreviewHeader title="Title" className="custom-header" data-testid="header" />)
|
||||
|
||||
expect(screen.getByTestId('header')).toHaveClass('custom-header')
|
||||
})
|
||||
|
||||
it('should pass rest props to the outer div', () => {
|
||||
render(<PreviewHeader title="Title" data-testid="header" id="header-1" aria-label="preview header" />)
|
||||
|
||||
const el = screen.getByTestId('header')
|
||||
expect(el).toHaveAttribute('id', 'header-1')
|
||||
expect(el).toHaveAttribute('aria-label', 'preview header')
|
||||
})
|
||||
|
||||
it('should render with empty string title', () => {
|
||||
render(<PreviewHeader title="" data-testid="header" />)
|
||||
|
||||
const header = screen.getByTestId('header')
|
||||
// Title div exists but is empty
|
||||
const titleDiv = header.querySelector('.uppercase')
|
||||
expect(titleDiv).toBeInTheDocument()
|
||||
expect(titleDiv?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Structure tests
|
||||
describe('Structure', () => {
|
||||
it('should render as a div element', () => {
|
||||
render(<PreviewHeader title="Title" data-testid="header" />)
|
||||
|
||||
expect(screen.getByTestId('header').tagName).toBe('DIV')
|
||||
})
|
||||
|
||||
it('should have title div as the first child', () => {
|
||||
render(<PreviewHeader title="Title" data-testid="header" />)
|
||||
|
||||
const header = screen.getByTestId('header')
|
||||
const firstChild = header.firstElementChild
|
||||
expect(firstChild).toHaveTextContent('Title')
|
||||
})
|
||||
|
||||
it('should place children after the title div', () => {
|
||||
render(
|
||||
<PreviewHeader title="Title" data-testid="header">
|
||||
<button>Action</button>
|
||||
</PreviewHeader>,
|
||||
)
|
||||
|
||||
const header = screen.getByTestId('header')
|
||||
const children = Array.from(header.children)
|
||||
expect(children).toHaveLength(2)
|
||||
expect(children[0]).toHaveTextContent('Title')
|
||||
expect(children[1]).toHaveTextContent('Action')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in title', () => {
|
||||
render(<PreviewHeader title="Test & <Special> 'Characters'" />)
|
||||
|
||||
expect(screen.getByText('Test & <Special> \'Characters\'')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long titles', () => {
|
||||
const longTitle = 'A'.repeat(500)
|
||||
render(<PreviewHeader title={longTitle} />)
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<PreviewHeader title="Title">
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</PreviewHeader>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with null children', () => {
|
||||
render(<PreviewHeader title="Title">{null}</PreviewHeader>)
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not crash on re-render with different title', () => {
|
||||
const { rerender } = render(<PreviewHeader title="First Title" />)
|
||||
|
||||
rerender(<PreviewHeader title="Second Title" />)
|
||||
|
||||
expect(screen.queryByText('First Title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Second Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user