From 83ef687d00a47fd45b1b8540d979d9628ac5f435 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 10 Feb 2026 13:59:54 +0800 Subject: [PATCH] test: enhance unit tests for various components including chat, datasets, and documents - Updated tests for to ensure proper async behavior. - Added comprehensive tests for , , and components, covering rendering, user interactions, and edge cases. - Introduced new tests for , , and components, validating rendering and user interactions. - Implemented tests for status filtering and document list query state to ensure correct functionality. These changes improve test coverage and reliability across multiple components. --- .../base/chat/embedded-chatbot/hooks.spec.tsx | 6 +- web/app/components/datasets/chunk.spec.tsx | 391 ++++++-- .../create/step-one/upgrade-card.spec.tsx | 108 +++ .../create/website/base/remaining.spec.tsx | 710 +++++++++++++++ .../datasets/create/website/no-data.spec.tsx | 230 +++++ .../datasets/create/website/preview.spec.tsx | 256 ++++++ .../detail/completed/common/summary.spec.tsx | 233 +++++ .../use-document-list-query-state.spec.ts | 439 +++++++++ .../hooks/use-documents-page-state.spec.ts | 711 +++++++++++++++ .../datasets/documents/status-filter.spec.ts | 156 ++++ .../documents/status-item/hooks.spec.ts | 115 +++ .../create/InfoPanel.spec.tsx | 94 ++ .../create/KnowledgeBaseInfo.spec.tsx | 153 ++++ .../extra-info/api-access/card.spec.tsx | 186 ++++ .../extra-info/service-api/card.spec.tsx | 151 ++++ .../datasets/formatted-text/index.spec.tsx | 552 ++++++++++++ .../components/presentational.spec.tsx | 836 ++++++++++++++++++ .../utils/extension-to-file-type.spec.ts | 119 +++ .../datasets/list/dataset-card/index.spec.tsx | 494 ++++++----- .../list/new-dataset-card/index.spec.tsx | 170 ++-- .../datasets/preview/container.spec.tsx | 176 ++++ .../datasets/preview/header.spec.tsx | 145 +++ 22 files changed, 6070 insertions(+), 361 deletions(-) create mode 100644 web/app/components/datasets/create/step-one/upgrade-card.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/remaining.spec.tsx create mode 100644 web/app/components/datasets/create/website/no-data.spec.tsx create mode 100644 web/app/components/datasets/create/website/preview.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/summary.spec.tsx create mode 100644 web/app/components/datasets/documents/hooks/use-document-list-query-state.spec.ts create mode 100644 web/app/components/datasets/documents/hooks/use-documents-page-state.spec.ts create mode 100644 web/app/components/datasets/documents/status-filter.spec.ts create mode 100644 web/app/components/datasets/documents/status-item/hooks.spec.ts create mode 100644 web/app/components/datasets/external-knowledge-base/create/InfoPanel.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/KnowledgeBaseInfo.spec.tsx create mode 100644 web/app/components/datasets/extra-info/api-access/card.spec.tsx create mode 100644 web/app/components/datasets/extra-info/service-api/card.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/presentational.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/utils/extension-to-file-type.spec.ts create mode 100644 web/app/components/datasets/preview/container.spec.tsx create mode 100644 web/app/components/datasets/preview/header.spec.tsx 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(