diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
index 4088e709d1..06563832f1 100644
--- a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
@@ -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)
+ })
})
})
diff --git a/web/app/components/datasets/chunk.spec.tsx b/web/app/components/datasets/chunk.spec.tsx
index d3dc011aef..7be74ebb7e 100644
--- a/web/app/components/datasets/chunk.spec.tsx
+++ b/web/app/components/datasets/chunk.spec.tsx
@@ -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'>) => (
+
+ ),
+}))
-describe('ChunkLabel', () => {
- it('should render label text', () => {
- render()
- expect(screen.getByText('Chunk 1')).toBeInTheDocument()
- })
-
- it('should render character count', () => {
- render()
- expect(screen.getByText('150 characters')).toBeInTheDocument()
- })
-
- it('should render separator dot', () => {
- render()
- expect(screen.getByText('ยท')).toBeInTheDocument()
- })
-
- it('should render with zero character count', () => {
- render()
- expect(screen.getByText('0 characters')).toBeInTheDocument()
- })
-
- it('should render with large character count', () => {
- render()
- expect(screen.getByText('999999 characters')).toBeInTheDocument()
- })
-})
-
-describe('ChunkContainer', () => {
- it('should render label and character count', () => {
- render(Content)
- expect(screen.getByText('Container 1')).toBeInTheDocument()
- expect(screen.getByText('200 characters')).toBeInTheDocument()
- })
-
- it('should render children content', () => {
- render(Test Content)
- expect(screen.getByText('Test Content')).toBeInTheDocument()
- })
-
- it('should render with complex children', () => {
- render(
-
-
- Nested content
-
- ,
- )
- expect(screen.getByTestId('child-div')).toBeInTheDocument()
- expect(screen.getByText('Nested content')).toBeInTheDocument()
- })
-
- it('should render empty children', () => {
- render({null})
- 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 {
+ return {
+ question: 'What is Dify?',
+ answer: 'Dify is an open-source LLM app development platform.',
+ ...overrides,
}
+}
- it('should render question text', () => {
- render()
- 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()
- expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
+ describe('Rendering', () => {
+ it('should render the label text', () => {
+ render()
+
+ expect(screen.getByText('Chunk #1')).toBeInTheDocument()
+ })
+
+ it('should render the character count with unit', () => {
+ render()
+
+ expect(screen.getByText('256 characters')).toBeInTheDocument()
+ })
+
+ it('should render the SelectionMod icon', () => {
+ render()
+
+ expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
+ })
+
+ it('should render a middle dot separator between label and count', () => {
+ render()
+
+ expect(screen.getByText('ยท')).toBeInTheDocument()
+ })
})
- it('should render Q label', () => {
- render()
- expect(screen.getByText('Q')).toBeInTheDocument()
+ describe('Props', () => {
+ it('should display zero character count', () => {
+ render()
+
+ expect(screen.getByText('0 characters')).toBeInTheDocument()
+ })
+
+ it('should display large character counts', () => {
+ render()
+
+ expect(screen.getByText('999999 characters')).toBeInTheDocument()
+ })
})
- it('should render A label', () => {
- render()
- expect(screen.getByText('A')).toBeInTheDocument()
- })
+ describe('Edge Cases', () => {
+ it('should render with empty label', () => {
+ render()
- it('should render with empty strings', () => {
- render()
- 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()
- expect(screen.getByText(longQuestion)).toBeInTheDocument()
- expect(screen.getByText(longAnswer)).toBeInTheDocument()
- })
+ it('should render with special characters in label', () => {
+ render()
- it('should render with special characters', () => {
- render(?', answer: '& special chars!' }} />)
- expect(screen.getByText('What about ' })
+
+ // Act
+ render()
+
+ // Assert - React escapes HTML by default
+ expect(screen.getByText('')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // CSS Module Classes
+ // --------------------------------------------------------------------------
+ describe('CSS Module Classes', () => {
+ it('should apply filePreview class to root container', () => {
+ // Arrange
+ const payload = createPayload()
+
+ // Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const root = container.firstElementChild
+ expect(root?.className).toContain('filePreview')
+ expect(root?.className).toContain('h-full')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/summary.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/summary.spec.tsx
new file mode 100644
index 0000000000..2158fa39d5
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/summary.spec.tsx
@@ -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()
+ expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
+ })
+
+ it('should render the summary heading with divider', () => {
+ render()
+ expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
+ })
+
+ it('should render summary text when provided', () => {
+ render()
+ expect(screen.getByText('My summary content')).toBeInTheDocument()
+ })
+ })
+
+ // Props: tests different prop combinations
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ const { container } = render()
+ 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()
+ 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()
+ // Heading should still render
+ expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
+ })
+
+ it('should handle empty string summary', () => {
+ render()
+ expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
+ })
+
+ it('should handle summary with special characters', () => {
+ const summary = 'bold & "quotes"'
+ render()
+ expect(screen.getByText(summary)).toBeInTheDocument()
+ })
+
+ it('should handle very long summary', () => {
+ const longSummary = 'A'.repeat(1000)
+ render()
+ expect(screen.getByText(longSummary)).toBeInTheDocument()
+ })
+
+ it('should handle both className and summary as undefined', () => {
+ const { container } = render()
+ 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()
+ // Should not crash even for non-SUMMARIZING status
+ })
+
+ it('should render badge when status is SUMMARIZING', () => {
+ render()
+ expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument()
+ })
+
+ it('should not render badge when status is not SUMMARIZING', () => {
+ render()
+ 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()
+ // 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()
+ expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
+ })
+
+ it('should not render badge for lowercase summarizing', () => {
+ render()
+ expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
+ })
+
+ it('should not render badge for DONE status', () => {
+ render()
+ expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument()
+ })
+
+ it('should not render badge for FAILED status', () => {
+ render()
+ 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()
+ expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
+ })
+
+ it('should render the summary label', () => {
+ render()
+ expect(screen.getByText(/segment\.summary/)).toBeInTheDocument()
+ })
+
+ it('should render textarea with placeholder', () => {
+ render()
+ 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()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('My summary')
+ })
+
+ it('should display empty string when value is undefined', () => {
+ render()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('')
+ })
+
+ it('should call onChange when textarea value changes', () => {
+ const onChange = vi.fn()
+ render()
+ 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()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toBeDisabled()
+ })
+
+ it('should enable textarea when disabled is false', () => {
+ render()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).not.toBeDisabled()
+ })
+
+ it('should enable textarea when disabled is undefined', () => {
+ render()
+ 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()
+ const textarea = screen.getByRole('textbox')
+ expect(() => {
+ fireEvent.change(textarea, { target: { value: 'typed' } })
+ }).not.toThrow()
+ })
+
+ it('should handle empty string value', () => {
+ render()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('')
+ })
+
+ it('should handle very long value', () => {
+ const longValue = 'B'.repeat(5000)
+ render()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue(longValue)
+ })
+
+ it('should handle value with special characters', () => {
+ const special = ''
+ render()
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue(special)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/hooks/use-document-list-query-state.spec.ts b/web/app/components/datasets/documents/hooks/use-document-list-query-state.spec.ts
new file mode 100644
index 0000000000..b8de04387e
--- /dev/null
+++ b/web/app/components/datasets/documents/hooks/use-document-list-query-state.spec.ts
@@ -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')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/hooks/use-documents-page-state.spec.ts b/web/app/components/datasets/documents/hooks/use-documents-page-state.spec.ts
new file mode 100644
index 0000000000..013b14781c
--- /dev/null
+++ b/web/app/components/datasets/documents/hooks/use-documents-page-state.spec.ts
@@ -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 {
+ return {
+ data: [],
+ has_more: false,
+ total: 0,
+ page: 1,
+ limit: 10,
+ ...overrides,
+ }
+}
+
+// Factory for creating a minimal document item
+function createDocumentItem(overrides: Record = {}) {
+ 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')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/status-filter.spec.ts b/web/app/components/datasets/documents/status-filter.spec.ts
new file mode 100644
index 0000000000..8310e04878
--- /dev/null
+++ b/web/app/components/datasets/documents/status-filter.spec.ts
@@ -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')
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/status-item/hooks.spec.ts b/web/app/components/datasets/documents/status-item/hooks.spec.ts
new file mode 100644
index 0000000000..2cc20bbf86
--- /dev/null
+++ b/web/app/components/datasets/documents/status-item/hooks.spec.ts
@@ -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) {
+ 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')
+ }
+ })
+})
diff --git a/web/app/components/datasets/external-knowledge-base/create/InfoPanel.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/InfoPanel.spec.tsx
new file mode 100644
index 0000000000..07ac518fd0
--- /dev/null
+++ b/web/app/components/datasets/external-knowledge-base/create/InfoPanel.spec.tsx
@@ -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()
+ expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument()
+ })
+
+ it('should render the title text', () => {
+ render()
+ expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument()
+ })
+
+ it('should render the front content text', () => {
+ render()
+ expect(screen.getByText(/connectDatasetIntro\.content\.front/)).toBeInTheDocument()
+ })
+
+ it('should render the content link', () => {
+ render()
+ expect(screen.getByText(/connectDatasetIntro\.content\.link/)).toBeInTheDocument()
+ })
+
+ it('should render the end content text', () => {
+ render()
+ expect(screen.getByText(/connectDatasetIntro\.content\.end/)).toBeInTheDocument()
+ })
+
+ it('should render the learn more link', () => {
+ render()
+ expect(screen.getByText(/connectDatasetIntro\.learnMore/)).toBeInTheDocument()
+ })
+
+ it('should render the book icon', () => {
+ const { container } = render()
+ 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()
+ 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()
+ 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()
+ 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()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('w-[360px]')
+ })
+
+ it('should have correct panel background', () => {
+ const { container } = render()
+ const panel = container.querySelector('.bg-background-section')
+ expect(panel).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.spec.tsx
new file mode 100644
index 0000000000..eef044b72b
--- /dev/null
+++ b/web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.spec.tsx
@@ -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()
+ expect(screen.getByText(/externalKnowledgeName/)).toBeInTheDocument()
+ })
+
+ it('should render the name label', () => {
+ render()
+ expect(screen.getByText(/externalKnowledgeName(?!Placeholder)/)).toBeInTheDocument()
+ })
+
+ it('should render the description label', () => {
+ render()
+ expect(screen.getByText(/externalKnowledgeDescription(?!Placeholder)/)).toBeInTheDocument()
+ })
+
+ it('should render name input with placeholder', () => {
+ render()
+ const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/)
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should render description textarea with placeholder', () => {
+ render()
+ 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()
+ const input = screen.getByDisplayValue('My Knowledge Base')
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should display description in the textarea', () => {
+ render()
+ const textarea = screen.getByDisplayValue('A description')
+ expect(textarea).toBeInTheDocument()
+ })
+
+ it('should call onChange with name when name input changes', () => {
+ const onChange = vi.fn()
+ render()
+ 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()
+ 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()
+ 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()
+ 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()
+ const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/)
+ expect(input).toHaveValue('')
+ })
+
+ it('should handle undefined description', () => {
+ render()
+ const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/)
+ expect(textarea).toBeInTheDocument()
+ })
+
+ it('should handle very long name', () => {
+ const longName = 'K'.repeat(500)
+ render()
+ const input = screen.getByDisplayValue(longName)
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should handle very long description', () => {
+ const longDesc = 'D'.repeat(2000)
+ render()
+ const textarea = screen.getByDisplayValue(longDesc)
+ expect(textarea).toBeInTheDocument()
+ })
+
+ it('should handle special characters in name', () => {
+ const specialName = 'Test & "quotes" '
+ render()
+ const input = screen.getByDisplayValue(specialName)
+ expect(input).toBeInTheDocument()
+ })
+
+ it('should apply filled text color class when description has content', () => {
+ const { container } = render()
+ 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()
+ const textarea = container.querySelector('textarea')
+ expect(textarea).toHaveClass('text-components-input-text-placeholder')
+ })
+ })
+})
diff --git a/web/app/components/datasets/extra-info/api-access/card.spec.tsx b/web/app/components/datasets/extra-info/api-access/card.spec.tsx
new file mode 100644
index 0000000000..e21cf8e2a0
--- /dev/null
+++ b/web/app/components/datasets/extra-info/api-access/card.spec.tsx
@@ -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 = vi.fn()
+let mockIsCurrentWorkspaceManager = true
+
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: Record) => unknown) =>
+ selector({
+ dataset: { id: mockDatasetId },
+ mutateDatasetRes: mockMutateDatasetRes,
+ }),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useSelector: (selector: (state: Record) => 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()
+ expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
+ })
+
+ it('should render without crashing when api is disabled', () => {
+ render()
+ expect(screen.getByText(/serviceApi\.disabled/)).toBeInTheDocument()
+ })
+
+ it('should render API access tip text', () => {
+ render()
+ expect(screen.getByText(/appMenus\.apiAccessTip/)).toBeInTheDocument()
+ })
+
+ it('should render API reference link', () => {
+ render()
+ 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()
+ expect(screen.getByText(/apiInfo\.doc/)).toBeInTheDocument()
+ })
+
+ it('should open API reference link in new tab', () => {
+ render()
+ 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()
+ const enabledText = screen.getByText(/serviceApi\.enabled/)
+ expect(enabledText).toHaveClass('text-text-success')
+ })
+
+ it('should show warning text when disabled', () => {
+ render()
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ const switchButton = screen.getByRole('switch')
+ fireEvent.click(switchButton)
+
+ await waitFor(() => {
+ expect(mockEnableApi).toHaveBeenCalledWith('')
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/extra-info/service-api/card.spec.tsx b/web/app/components/datasets/extra-info/service-api/card.spec.tsx
new file mode 100644
index 0000000000..5db0f12442
--- /dev/null
+++ b/web/app/components/datasets/extra-info/service-api/card.spec.tsx
@@ -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 }) => (
+
+ {children}
+
+ )
+}
+
+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()
+ expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument()
+ })
+
+ it('should render card title', () => {
+ renderWithProviders()
+ expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument()
+ })
+
+ it('should render enabled status', () => {
+ renderWithProviders()
+ expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
+ })
+
+ it('should render endpoint label', () => {
+ renderWithProviders()
+ expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument()
+ })
+
+ it('should render the API base URL', () => {
+ renderWithProviders()
+ expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
+ })
+
+ it('should render API key button', () => {
+ renderWithProviders()
+ expect(screen.getByText(/serviceApi\.card\.apiKey/)).toBeInTheDocument()
+ })
+
+ it('should render API reference button', () => {
+ renderWithProviders()
+ expect(screen.getByText(/serviceApi\.card\.apiReference/)).toBeInTheDocument()
+ })
+ })
+
+ // Props: tests different apiBaseUrl values
+ describe('Props', () => {
+ it('should display provided apiBaseUrl', () => {
+ renderWithProviders()
+ expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument()
+ })
+
+ it('should show green indicator when apiBaseUrl is provided', () => {
+ renderWithProviders()
+ // 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()
+ // 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()
+
+ 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()
+ 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()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('w-[360px]')
+ })
+
+ it('should have rounded corners', () => {
+ const { container } = renderWithProviders()
+ 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()
+ // 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()
+ expect(screen.getByText(longUrl)).toBeInTheDocument()
+ })
+
+ it('should handle apiBaseUrl with special characters', () => {
+ const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar'
+ renderWithProviders()
+ expect(screen.getByText(specialUrl)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/formatted-text/index.spec.tsx b/web/app/components/datasets/formatted-text/index.spec.tsx
new file mode 100644
index 0000000000..ea46d58b9a
--- /dev/null
+++ b/web/app/components/datasets/formatted-text/index.spec.tsx
@@ -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 }) => (
+ {children}
+ ),
+ }
+})
+
+// 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(Hello World)
+
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
+ })
+
+ it('should render as a p element', () => {
+ render(content)
+
+ expect(screen.getByText('content').tagName).toBe('P')
+ })
+
+ it('should apply default leading-7 class', () => {
+ render(text)
+
+ expect(screen.getByText('text')).toHaveClass('leading-7')
+ })
+
+ it('should merge custom className with default class', () => {
+ render(text)
+
+ 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(text)
+
+ const el = screen.getByTestId('formatted')
+ expect(el).toHaveAttribute('id', 'my-id')
+ })
+
+ it('should render nested elements as children', () => {
+ render(
+
+ nested
+ ,
+ )
+
+ expect(screen.getByText('nested')).toBeInTheDocument()
+ })
+
+ it('should render with empty children without crashing', () => {
+ const { container } = render()
+
+ expect(container.querySelector('p')).toBeInTheDocument()
+ })
+})
+
+// Tests for shared slice wrapper components
+describe('SliceContainer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render as a span element', () => {
+ render()
+
+ expect(screen.getByTestId('container').tagName).toBe('SPAN')
+ })
+
+ it('should apply default className', () => {
+ render()
+
+ expect(screen.getByTestId('container')).toHaveClass('group', 'mr-1', 'select-none', 'align-bottom', 'text-sm')
+ })
+
+ it('should merge custom className', () => {
+ render()
+
+ const el = screen.getByTestId('container')
+ expect(el).toHaveClass('group')
+ expect(el).toHaveClass('extra')
+ })
+
+ it('should pass rest props to span', () => {
+ render()
+
+ 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(Label Text)
+
+ expect(screen.getByText('Label Text')).toBeInTheDocument()
+ })
+
+ it('should apply default classes including uppercase and bg classes', () => {
+ render(L)
+
+ const outer = screen.getByTestId('label')
+ expect(outer).toHaveClass('uppercase')
+ expect(outer).toHaveClass('px-1')
+ })
+
+ it('should merge custom className', () => {
+ render(L)
+
+ expect(screen.getByTestId('label')).toHaveClass('custom')
+ })
+
+ it('should apply labelInnerClassName to inner span', () => {
+ render(L)
+
+ 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(L)
+
+ expect(screen.getByTestId('label')).toHaveAttribute('aria-label', 'chunk label')
+ })
+})
+
+describe('SliceContent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render children', () => {
+ render(Some content)
+
+ expect(screen.getByText('Some content')).toBeInTheDocument()
+ })
+
+ it('should apply default classes', () => {
+ render(text)
+
+ const el = screen.getByTestId('content')
+ expect(el).toHaveClass('whitespace-pre-line', 'break-all', 'px-1', 'leading-7')
+ })
+
+ it('should merge custom className', () => {
+ render(text)
+
+ expect(screen.getByTestId('content')).toHaveClass('my-class')
+ })
+
+ it('should have correct displayName', () => {
+ expect(SliceContent.displayName).toBe('SliceContent')
+ })
+
+ it('should pass rest props', () => {
+ render(text)
+
+ expect(screen.getByTestId('content')).toHaveAttribute('title', 'hover')
+ })
+})
+
+describe('SliceDivider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render a span element', () => {
+ render()
+
+ expect(screen.getByTestId('divider').tagName).toBe('SPAN')
+ })
+
+ it('should contain a zero-width space character', () => {
+ render()
+
+ expect(screen.getByTestId('divider').textContent).toContain('\u200B')
+ })
+
+ it('should apply default classes', () => {
+ render()
+
+ expect(screen.getByTestId('divider')).toHaveClass('px-[1px]', 'text-sm')
+ })
+
+ it('should merge custom className', () => {
+ render()
+
+ expect(screen.getByTestId('divider')).toHaveClass('extra')
+ })
+
+ it('should have correct displayName', () => {
+ expect(SliceDivider.displayName).toBe('SliceDivider')
+ })
+
+ it('should pass rest props', () => {
+ render()
+
+ 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()
+
+ 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()
+
+ expect(findDividerSpan(container)).toBeTruthy()
+ })
+
+ it('should hide divider when showDivider is false', () => {
+ const { container } = render()
+
+ expect(findDividerSpan(container)).toBeUndefined()
+ })
+
+ it('should not show delete button when floating is closed', () => {
+ render()
+
+ expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument()
+ })
+
+ it('should show delete button when onOpenChange triggers open', () => {
+ render()
+
+ // 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()
+
+ // 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()
+
+ 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()
+
+ expect(screen.getByTestId('edit-slice')).toHaveClass('custom-slice')
+ })
+
+ it('should apply labelClassName to SliceLabel', () => {
+ render()
+
+ const labelEl = screen.getByText('S1').parentElement
+ expect(labelEl).toHaveClass('label-extra')
+ })
+
+ it('should apply contentClassName to SliceContent', () => {
+ render()
+
+ expect(screen.getByText('Sample text content')).toHaveClass('content-extra')
+ })
+
+ it('should apply labelInnerClassName to SliceLabel inner span', () => {
+ render()
+
+ expect(screen.getByText('S1')).toHaveClass('inner-label')
+ })
+
+ it('should apply destructive styles when hovering on delete button container', async () => {
+ render()
+
+ // 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByTestId('edit-slice')).toBeInTheDocument()
+ })
+
+ it('should stop event propagation on delete click', () => {
+ const parentClick = vi.fn()
+ render(
+
+
+
,
+ )
+
+ 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()
+
+ expect(screen.getByText('P1')).toBeInTheDocument()
+ expect(screen.getByText('Preview text')).toBeInTheDocument()
+ })
+
+ it('should not show tooltip by default', () => {
+ render()
+
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
+ })
+
+ it('should show tooltip when onOpenChange triggers open', () => {
+ render()
+
+ act(() => {
+ capturedOnOpenChange?.(true)
+ })
+
+ expect(screen.getByText('Tooltip content')).toBeInTheDocument()
+ })
+
+ it('should hide tooltip when onOpenChange triggers close', () => {
+ render()
+
+ 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()
+
+ expect(findDividerSpan(container)).toBeTruthy()
+ })
+
+ it('should apply custom className to SliceContainer', () => {
+ render()
+
+ expect(screen.getByTestId('preview-slice')).toHaveClass('preview-custom')
+ })
+
+ it('should apply labelInnerClassName to the label inner span', () => {
+ render()
+
+ expect(screen.getByText('P1')).toHaveClass('label-inner')
+ })
+
+ it('should apply dividerClassName to SliceDivider', () => {
+ const { container } = render()
+
+ const divider = findDividerSpan(container)
+ expect(divider).toHaveClass('divider-custom')
+ })
+
+ it('should pass rest props to SliceContainer', () => {
+ render()
+
+ expect(screen.getByTestId('preview-slice')).toBeInTheDocument()
+ })
+
+ it('should render ReactNode tooltip content when open', () => {
+ render(Rich tooltip} />)
+
+ act(() => {
+ capturedOnOpenChange?.(true)
+ })
+
+ expect(screen.getByText('Rich tooltip')).toBeInTheDocument()
+ })
+
+ it('should render ReactNode label', () => {
+ render(Emphasis} />)
+
+ expect(screen.getByText('Emphasis')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/datasets/hit-testing/components/presentational.spec.tsx b/web/app/components/datasets/hit-testing/components/presentational.spec.tsx
new file mode 100644
index 0000000000..4a203d5aa8
--- /dev/null
+++ b/web/app/components/datasets/hit-testing/components/presentational.spec.tsx
@@ -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 => ({
+ 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 => ({
+ 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()
+
+ // 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()
+
+ // Assert
+ const progressBar = container.querySelector('[style]')
+ expect(progressBar).toHaveStyle({ width: '75%' })
+ })
+
+ it('should render with besideChunkName styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // Assert
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('should return null when value is 0', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('should return null when value is NaN', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.innerHTML).toBe('')
+ })
+ })
+
+ // Edge case tests
+ describe('Edge Cases', () => {
+ it('should render very small score values', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('0.01')).toBeInTheDocument()
+ })
+
+ it('should render score with many decimals truncated to 2', () => {
+ // Arrange & Act
+ render()
+
+ // 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()
+
+ // 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()
+
+ // Assert
+ expect(container.firstElementChild?.className).toContain('custom-mask')
+ })
+
+ it('should render without custom className', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // 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()
+
+ // Assert
+ expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument()
+ })
+
+ it('should render the history icon', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('should render inside a styled container', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // 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()
+
+ // Assert
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should display the current text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('textbox')).toHaveValue('Hello world')
+ })
+
+ it('should show character count', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('5/200')).toBeInTheDocument()
+ })
+
+ it('should show 0/200 for empty text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('0/200')).toBeInTheDocument()
+ })
+
+ it('should render placeholder text', () => {
+ // Arrange & Act
+ render()
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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()
+
+ // 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()
+
+ // 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(
+ ,
+ )
+
+ // 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()
+
+ // 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(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('My Document.pdf')).toBeInTheDocument()
+ })
+
+ it('should render the "open" button text', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText(/open/i)).toBeInTheDocument()
+ })
+
+ it('should render the file icon', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Chunk-03')).toBeInTheDocument()
+ })
+
+ it('should render the word count', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText(/250/)).toBeInTheDocument()
+ expect(screen.getByText(/characters/i)).toBeInTheDocument()
+ })
+
+ it('should render the score component', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('0.75')).toBeInTheDocument()
+ expect(screen.getByText('score')).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstElementChild?.className).toContain('custom-meta')
+ })
+
+ it('should render dot separator', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // 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()
+
+ // 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()
+
+ // Assert
+ expect(screen.getByText('0.88')).toBeInTheDocument()
+ })
+
+ it('should render the content text', () => {
+ // Arrange
+ const payload = createChildChunkPayload({ content: 'Sample chunk text' })
+
+ // Act
+ render()
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // Assert
+ expect(screen.queryByText(/chunkDetail/i)).not.toBeInTheDocument()
+ })
+
+ it('should call showDetailModal when card is clicked', () => {
+ // Arrange
+ const payload = createExternalPayload()
+ mockIsShowDetailModal = false
+
+ render()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // 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()
+
+ // Assert - no score displayed
+ expect(screen.queryByText('score')).not.toBeInTheDocument()
+ })
+
+ it('should handle large positionId values', () => {
+ // Arrange
+ const payload = createExternalPayload()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-999')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/hit-testing/utils/extension-to-file-type.spec.ts b/web/app/components/datasets/hit-testing/utils/extension-to-file-type.spec.ts
new file mode 100644
index 0000000000..a9b137d85e
--- /dev/null
+++ b/web/app/components/datasets/hit-testing/utils/extension-to-file-type.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-card/index.spec.tsx b/web/app/components/datasets/list/dataset-card/index.spec.tsx
index dd27eaa262..be97bea649 100644
--- a/web/app/components/datasets/list/dataset-card/index.spec.tsx
+++ b/web/app/components/datasets/list/dataset-card/index.spec.tsx
@@ -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()
- 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 => ({
- 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 => ({
+ 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()
- 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()
+ expect(screen.getByText('My knowledge base')).toBeInTheDocument()
+ })
+
+ it('should set title attribute to description', () => {
+ const dataset = createMockDataset({ description: 'Hover text' })
+ render()
+ expect(screen.getByTitle('Hover text')).toBeInTheDocument()
+ })
})
- it('should render dataset name', () => {
- const dataset = createMockDataset({ name: 'Custom Dataset Name' })
- render()
- 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()
+ 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()
+ const descDiv = screen.getByTitle(dataset.description)
+ expect(descDiv).not.toHaveClass('opacity-30')
+ })
})
- it('should render dataset description', () => {
- const dataset = createMockDataset({ description: 'Custom Description' })
- render()
- expect(screen.getByText('Custom Description')).toBeInTheDocument()
- })
+ describe('Edge Cases', () => {
+ it('should handle empty description', () => {
+ const dataset = createMockDataset({ description: '' })
+ render()
+ const descDiv = screen.getByTitle('')
+ expect(descDiv).toBeInTheDocument()
+ expect(descDiv).toHaveTextContent('')
+ })
- it('should render document count', () => {
- render()
- expect(screen.getByText('10')).toBeInTheDocument()
- })
-
- it('should render app count', () => {
- render()
- expect(screen.getByText('5')).toBeInTheDocument()
+ it('should handle long description', () => {
+ const longDesc = 'X'.repeat(500)
+ const dataset = createMockDataset({ description: longDesc })
+ render()
+ expect(screen.getByText(longDesc)).toBeInTheDocument()
+ })
})
})
- describe('Props', () => {
- it('should handle external provider', () => {
- const dataset = createMockDataset({ provider: 'external' })
- render()
- 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()
+ expect(screen.getByText('15')).toBeInTheDocument()
+ })
+
+ it('should render app count for non-external provider', () => {
+ const dataset = createMockDataset({ app_count: 7, provider: 'vendor' })
+ render()
+ expect(screen.getByText('7')).toBeInTheDocument()
+ })
+
+ it('should render update time', () => {
+ const dataset = createMockDataset()
+ render()
+ expect(screen.getByText(/updated/i)).toBeInTheDocument()
+ })
})
- it('should handle rag_pipeline runtime mode', () => {
- const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
- render()
- 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()
+ 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()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ })
+
+ it('should not show app count when provider is external', () => {
+ const dataset = createMockDataset({ provider: 'external', app_count: 99 })
+ render()
+ expect(screen.queryByText('99')).not.toBeInTheDocument()
+ })
+
+ it('should have opacity when embedding_available is false', () => {
+ const dataset = createMockDataset({ embedding_available: false })
+ const { container } = render()
+ 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()
+ // 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()
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+
+ it('should handle large numbers', () => {
+ const dataset = createMockDataset({
+ document_count: 100000,
+ total_available_documents: 100000,
+ app_count: 50000,
+ })
+ render()
+ 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()
+ // Integration tests for OperationItem component
+ describe('OperationItem', () => {
+ const MockIcon = ({ className }: { className?: string }) => (
+
+ )
- 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()
+ 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()
+ describe('User Interactions', () => {
+ it('should call handleClick when clicked', () => {
+ const handleClick = vi.fn()
+ render()
- 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()
+ it('should prevent default and stop propagation on click', () => {
+ const handleClick = vi.fn()
+ render()
- 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()
- 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()
- 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()
- expect(screen.getByText('Test Dataset')).toBeInTheDocument()
- })
-
- it('should handle embedding not available', () => {
- const dataset = createMockDataset({ embedding_available: false })
- render()
- expect(screen.getByText('Test Dataset')).toBeInTheDocument()
- })
-
- it('should handle undefined onSuccess', () => {
- render()
- expect(screen.getByText('Test Dataset')).toBeInTheDocument()
- })
- })
-
- describe('Tag Area Click', () => {
- it('should stop propagation and prevent default when tag area is clicked', () => {
- render()
-
- // 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()
+ describe('Edge Cases', () => {
+ it('should not throw when handleClick is undefined', () => {
+ render()
+ 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()
+ 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()
+ expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
+ })
+
+ it('should render export pipeline when showExportPipeline is true', () => {
+ render()
+ expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
+ })
+
+ it('should not render export pipeline when showExportPipeline is false', () => {
+ render()
+ expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
+ })
+
+ it('should render delete when showDelete is true', () => {
+ render()
+ expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
+ })
+
+ it('should not render delete when showDelete is false', () => {
+ render()
+ expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call openRenameModal when edit is clicked', () => {
+ const openRenameModal = vi.fn()
+ render()
+
+ 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()
+
+ 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()
+
+ 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()
+ 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()
+ expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument()
+ })
+
+ it('should not render divider when showDelete is false', () => {
+ const { container } = render()
+ expect(container.querySelector('.bg-divider-subtle')).toBeNull()
+ })
})
})
})
diff --git a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
index 2ce66e134b..ff70e8f72f 100644
--- a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
+++ b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
@@ -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()
- 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()
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ expect(screen.getByText('Create')).toBeInTheDocument()
+ })
+
+ it('should render icon with correct sizing class', () => {
+ const { container } = render()
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
})
- it('should render create dataset option', () => {
- render()
- expect(screen.getByText(/createDataset/)).toBeInTheDocument()
+ describe('Props', () => {
+ it('should set correct href on the link', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', '/datasets/create')
+ })
+
+ it('should render different text based on props', () => {
+ render()
+ expect(screen.getByText('Custom Text')).toBeInTheDocument()
+ })
+
+ it('should render different href based on props', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', '/custom-path')
+ })
})
- it('should render create from pipeline option', () => {
- render()
- expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
+ describe('Styles', () => {
+ it('should have correct link styling', () => {
+ render()
+ 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()
+ const textSpan = screen.getByText('Text Style')
+ expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
+ })
})
- it('should render connect dataset option', () => {
- render()
- expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
+ describe('Edge Cases', () => {
+ it('should handle empty text', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ })
+
+ it('should handle long text', () => {
+ const longText = 'Z'.repeat(200)
+ render()
+ 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()
+ // 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()
- const links = screen.getAllByRole('link')
- expect(links[0]).toHaveAttribute('href', '/datasets/create')
+ it('should render the create dataset option', () => {
+ render()
+ expect(screen.getByText(/createDataset/)).toBeInTheDocument()
+ })
+
+ it('should render the create from pipeline option', () => {
+ render()
+ expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
+ })
+
+ it('should render the connect dataset option', () => {
+ render()
+ expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
+ })
})
- it('should have correct href for create from pipeline', () => {
- render()
- 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()
+ 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()
+ 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()
+ 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()
- const links = screen.getAllByRole('link')
- expect(links[2]).toHaveAttribute('href', '/datasets/connect')
- })
- })
-
- describe('Styles', () => {
- it('should have correct card styling', () => {
- const { container } = render()
- 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()
- const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
- expect(borderDiv).toBeInTheDocument()
- })
- })
-
- describe('Icons', () => {
- it('should render three icons for three options', () => {
- const { container } = render()
- // 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()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex', 'flex-col', 'rounded-xl')
+ })
})
})
})
diff --git a/web/app/components/datasets/preview/container.spec.tsx b/web/app/components/datasets/preview/container.spec.tsx
new file mode 100644
index 0000000000..c721b1608a
--- /dev/null
+++ b/web/app/components/datasets/preview/container.spec.tsx
@@ -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(Header Title}>Body)
+
+ 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(Main content)
+
+ const mainEl = screen.getByRole('main')
+ expect(mainEl).toHaveTextContent('Main content')
+ })
+
+ it('should render both header and children simultaneously', () => {
+ render(
+ My Header}>
+ Body paragraph
+ ,
+ )
+
+ expect(screen.getByText('My Header')).toBeInTheDocument()
+ expect(screen.getByText('Body paragraph')).toBeInTheDocument()
+ })
+
+ it('should render without children', () => {
+ render()
+
+ 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(
+ Content,
+ )
+
+ expect(container.firstElementChild).toHaveClass('outer-class')
+ })
+
+ it('should apply mainClassName to the main element', () => {
+ render(
+ Content,
+ )
+
+ 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(
+ Content,
+ )
+
+ 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(
+
+ Content
+ ,
+ )
+
+ const inner = screen.getByTestId('inner-container')
+ expect(inner).toHaveAttribute('id', 'container-1')
+ })
+
+ it('should render ReactNode as header', () => {
+ render(
+ Complex}>
+ Content
+ ,
+ )
+
+ 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(Content)
+
+ 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(
+ Content,
+ )
+
+ 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(Content)
+
+ 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(Content)
+
+ const headerEl = screen.getByRole('banner')
+ expect(headerEl).toBeInTheDocument()
+ })
+
+ it('should render with null children', () => {
+ render({null})
+
+ expect(screen.getByRole('main')).toBeInTheDocument()
+ })
+
+ it('should render with multiple children', () => {
+ render(
+
+ Child 1
+ Child 2
+ Child 3
+ ,
+ )
+
+ 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(
+ Content A,
+ )
+
+ rerender(
+ Content B,
+ )
+
+ expect(screen.getByText('Second')).toBeInTheDocument()
+ expect(screen.getByText('Content B')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/preview/header.spec.tsx b/web/app/components/datasets/preview/header.spec.tsx
new file mode 100644
index 0000000000..3c3a62ed0b
--- /dev/null
+++ b/web/app/components/datasets/preview/header.spec.tsx
@@ -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()
+
+ expect(screen.getByText('Preview Title')).toBeInTheDocument()
+ })
+
+ it('should render children below the title', () => {
+ render(
+
+ Child content
+ ,
+ )
+
+ expect(screen.getByText('Title')).toBeInTheDocument()
+ expect(screen.getByText('Child content')).toBeInTheDocument()
+ })
+
+ it('should render without children', () => {
+ const { container } = render()
+
+ expect(container.firstElementChild).toBeInTheDocument()
+ expect(screen.getByText('Solo Title')).toBeInTheDocument()
+ })
+
+ it('should render title in an inner div with uppercase styling', () => {
+ render()
+
+ 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()
+
+ expect(screen.getByTestId('header')).toHaveClass('custom-header')
+ })
+
+ it('should pass rest props to the outer div', () => {
+ render()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByTestId('header').tagName).toBe('DIV')
+ })
+
+ it('should have title div as the first child', () => {
+ render()
+
+ const header = screen.getByTestId('header')
+ const firstChild = header.firstElementChild
+ expect(firstChild).toHaveTextContent('Title')
+ })
+
+ it('should place children after the title div', () => {
+ render(
+
+
+ ,
+ )
+
+ 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()
+
+ expect(screen.getByText('Test & \'Characters\'')).toBeInTheDocument()
+ })
+
+ it('should handle long titles', () => {
+ const longTitle = 'A'.repeat(500)
+ render()
+
+ expect(screen.getByText(longTitle)).toBeInTheDocument()
+ })
+
+ it('should render multiple children', () => {
+ render(
+
+ First
+ Second
+ ,
+ )
+
+ expect(screen.getByText('First')).toBeInTheDocument()
+ expect(screen.getByText('Second')).toBeInTheDocument()
+ })
+
+ it('should render with null children', () => {
+ render({null})
+
+ expect(screen.getByText('Title')).toBeInTheDocument()
+ })
+
+ it('should not crash on re-render with different title', () => {
+ const { rerender } = render()
+
+ rerender()
+
+ expect(screen.queryByText('First Title')).not.toBeInTheDocument()
+ expect(screen.getByText('Second Title')).toBeInTheDocument()
+ })
+ })
+})