Loading...
,
-}))
-
-// Mock Empty component
+// Mock child components
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
-
-
+
+
+
+ ),
+}))
+
+vi.mock('./skeleton/full-doc-list-skeleton', () => ({
+ default: () =>
Loading...
,
+}))
+
+vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
+ EditSlice: ({
+ label,
+ text,
+ onDelete,
+ className,
+ labelClassName,
+ onClick,
+ }: {
+ label: string
+ text: string
+ onDelete: () => void
+ className: string
+ labelClassName: string
+ contentClassName: string
+ labelInnerClassName: string
+ showDivider: boolean
+ onClick: (e: React.MouseEvent) => void
+ offsetOptions: unknown
+ }) => (
+
+ {label}
+ {text}
+
+
),
}))
-// Mock FormattedText and EditSlice
vi.mock('../../../formatted-text/formatted', () => ({
- FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
+ FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => (
{children}
),
}))
-vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
- EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
- label: string
- text: string
- onDelete: () => void
- onClick: (e: React.MouseEvent) => void
- labelClassName?: string
- contentClassName?: string
- }) => (
-
- {label}
- {text}
-
-
- ),
-}))
-
-// ============================================================================
-// Test Data Factories
-// ============================================================================
-
-const createMockChildChunk = (overrides: Partial
= {}): ChildChunkDetail => ({
- id: `child-${Math.random().toString(36).substr(2, 9)}`,
- position: 1,
- segment_id: 'segment-1',
- content: 'Child chunk content',
- word_count: 100,
- created_at: 1700000000,
- updated_at: 1700000000,
- type: 'automatic',
- ...overrides,
-})
-
-// ============================================================================
-// Tests
-// ============================================================================
-
describe('ChildSegmentList', () => {
- const defaultProps = {
- childChunks: [] as ChildChunkDetail[],
- parentChunkId: 'parent-1',
- enabled: true,
- }
-
beforeEach(() => {
vi.clearAllMocks()
- mockParentMode.current = 'paragraph'
- mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
+ mockParentMode = 'paragraph'
+ mockCurrChildChunk = null
})
+ const createMockChildChunk = (id: string, content: string, edited = false): ChildChunkDetail => ({
+ id,
+ content,
+ position: 1,
+ word_count: 10,
+ segment_id: 'seg-1',
+ created_at: Date.now(),
+ updated_at: edited ? Date.now() + 1000 : Date.now(),
+ type: 'automatic',
+ })
+
+ const defaultProps = {
+ childChunks: [createMockChildChunk('child-1', 'Child content 1')],
+ parentChunkId: 'parent-1',
+ handleInputChange: vi.fn(),
+ handleAddNewChildChunk: vi.fn(),
+ enabled: true,
+ onDelete: vi.fn(),
+ onClickSlice: vi.fn(),
+ total: 1,
+ inputValue: '',
+ onClearFilter: vi.fn(),
+ isLoading: false,
+ focused: false,
+ }
+
+ // Rendering tests
describe('Rendering', () => {
- it('should render with empty child chunks', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render total count text', () => {
+ // Arrange & Act
render()
- expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
+ // Assert
+ expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument()
})
- it('should render child chunks when provided', () => {
- const childChunks = [
- createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
- createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
- ]
+ it('should render add button', () => {
+ // Arrange & Act
+ render()
- render()
-
- // In paragraph mode, content is collapsed by default
- expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
- })
-
- it('should render total count correctly with total prop in full-doc mode', () => {
- mockParentMode.current = 'full-doc'
- const childChunks = [createMockChildChunk()]
-
- // Pass inputValue="" to ensure isSearching is false
- render()
-
- expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
- })
-
- it('should render loading skeleton in full-doc mode when loading', () => {
- mockParentMode.current = 'full-doc'
-
- render()
-
- expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
- })
-
- it('should not render loading skeleton when not loading', () => {
- mockParentMode.current = 'full-doc'
-
- render()
-
- expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
+ // Assert
+ expect(screen.getByText(/operation\.add/i)).toBeInTheDocument()
})
})
+ // Paragraph mode tests
describe('Paragraph Mode', () => {
beforeEach(() => {
- mockParentMode.current = 'paragraph'
+ mockParentMode = 'paragraph'
})
- it('should show collapse icon in paragraph mode', () => {
- const childChunks = [createMockChildChunk()]
+ it('should render collapsed by default in paragraph mode', () => {
+ // Arrange & Act
+ render()
- render()
-
- // Check for collapse/expand behavior
- const totalRow = screen.getByText(/1 child chunk/i).closest('div')
- expect(totalRow).toBeInTheDocument()
- })
-
- it('should toggle collapsed state when clicked', () => {
- const childChunks = [createMockChildChunk({ content: 'Test content' })]
-
- render()
-
- // Initially collapsed in paragraph mode - content should not be visible
+ // Assert - collapsed icon should be present
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
+ })
- // Find and click the toggle area
- const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
+ it('should expand when clicking toggle in paragraph mode', () => {
+ // Arrange
+ render()
- // Click to expand
+ // Act - click on the collapse toggle
+ const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
if (toggleArea)
fireEvent.click(toggleArea)
- // After expansion, content should be visible
+ // Assert - child chunks should be visible
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
- it('should apply opacity when disabled', () => {
- const { container } = render()
+ it('should collapse when clicking toggle again', () => {
+ // Arrange
+ render()
- const wrapper = container.firstChild
- expect(wrapper).toHaveClass('opacity-50')
- })
+ // Act - click twice
+ const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
+ if (toggleArea) {
+ fireEvent.click(toggleArea)
+ fireEvent.click(toggleArea)
+ }
- it('should not apply opacity when enabled', () => {
- const { container } = render()
-
- const wrapper = container.firstChild
- expect(wrapper).not.toHaveClass('opacity-50')
+ // Assert - child chunks should be hidden
+ expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
})
})
- describe('Full-Doc Mode', () => {
+ // Full doc mode tests
+ describe('Full Doc Mode', () => {
beforeEach(() => {
- mockParentMode.current = 'full-doc'
+ mockParentMode = 'full-doc'
})
- it('should show content by default in full-doc mode', () => {
- const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
+ it('should render input field in full-doc mode', () => {
+ // Arrange & Act
+ render()
- render()
-
- expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
- })
-
- it('should render search input in full-doc mode', () => {
- render()
-
- const input = document.querySelector('input')
+ // Assert
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
+ it('should render child chunks without collapse in full-doc mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
+ })
+
it('should call handleInputChange when input changes', () => {
- const handleInputChange = vi.fn()
+ // Arrange
+ const mockHandleInputChange = vi.fn()
+ render()
- render()
+ // Act
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'search term' } })
- const input = document.querySelector('input')
- if (input) {
- fireEvent.change(input, { target: { value: 'test search' } })
- expect(handleInputChange).toHaveBeenCalledWith('test search')
- }
+ // Assert
+ expect(mockHandleInputChange).toHaveBeenCalledWith('search term')
})
it('should show search results text when searching', () => {
- render()
+ // Arrange & Act
+ render()
- expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
+ // Assert
+ expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
- render(
- ,
- )
+ // Arrange & Act
+ render()
- expect(screen.getByTestId('empty-component')).toBeInTheDocument()
+ // Assert
+ expect(screen.getByTestId('empty')).toBeInTheDocument()
})
- it('should call onClearFilter when clear button clicked in empty state', () => {
- const onClearFilter = vi.fn()
+ it('should show loading skeleton when isLoading is true', () => {
+ // Arrange & Act
+ render()
- render(
- ,
- )
+ // Assert
+ expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument()
+ })
- const clearButton = screen.getByText('Clear Filter')
- fireEvent.click(clearButton)
+ it('should handle undefined total in full-doc mode', () => {
+ // Arrange & Act
+ const { container } = render()
- expect(onClearFilter).toHaveBeenCalled()
+ // Assert - component should render without crashing
+ expect(container.firstChild).toBeInTheDocument()
})
})
- describe('Child Chunk Items', () => {
- it('should render edited label when chunk is edited', () => {
- mockParentMode.current = 'full-doc'
- const editedChunk = createMockChildChunk({
- id: 'edited-chunk',
- position: 1,
- created_at: 1700000000,
- updated_at: 1700000001, // Different from created_at
- })
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call handleAddNewChildChunk when add button is clicked', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ const mockHandleAddNewChildChunk = vi.fn()
+ render()
- render()
+ // Act
+ fireEvent.click(screen.getByText(/operation\.add/i))
- expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
- })
-
- it('should not show edited label when chunk is not edited', () => {
- mockParentMode.current = 'full-doc'
- const normalChunk = createMockChildChunk({
- id: 'normal-chunk',
- position: 2,
- created_at: 1700000000,
- updated_at: 1700000000, // Same as created_at
- })
-
- render()
-
- expect(screen.getByText('C-2')).toBeInTheDocument()
- expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
- })
-
- it('should call onClickSlice when chunk is clicked', () => {
- mockParentMode.current = 'full-doc'
- const onClickSlice = vi.fn()
- const chunk = createMockChildChunk({ id: 'clickable-chunk' })
-
- render(
- ,
- )
-
- const editSlice = screen.getByTestId('edit-slice')
- fireEvent.click(editSlice)
-
- expect(onClickSlice).toHaveBeenCalledWith(chunk)
+ // Assert
+ expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1')
})
it('should call onDelete when delete button is clicked', () => {
- mockParentMode.current = 'full-doc'
- const onDelete = vi.fn()
- const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
+ // Arrange
+ mockParentMode = 'full-doc'
+ const mockOnDelete = vi.fn()
+ render()
- render(
- ,
- )
+ // Act
+ fireEvent.click(screen.getByTestId('delete-slice-btn'))
- const deleteButton = screen.getByTestId('delete-button')
- fireEvent.click(deleteButton)
-
- expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
+ // Assert
+ expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1')
})
- it('should apply focused styles when chunk is currently selected', () => {
- mockParentMode.current = 'full-doc'
- const chunk = createMockChildChunk({ id: 'focused-chunk' })
- mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
+ it('should call onClickSlice when slice is clicked', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ const mockOnClickSlice = vi.fn()
+ render()
- render()
+ // Act
+ fireEvent.click(screen.getByTestId('click-slice-btn'))
- const label = screen.getByTestId('edit-slice-label')
+ // Assert
+ expect(mockOnClickSlice).toHaveBeenCalledWith(expect.objectContaining({ id: 'child-1' }))
+ })
+
+ it('should call onClearFilter when clear filter button is clicked', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ const mockOnClearFilter = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('clear-filter-btn'))
+
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalled()
+ })
+ })
+
+ // Focused state
+ describe('Focused State', () => {
+ it('should apply focused style when currChildChunk matches', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ mockCurrChildChunk = { childChunkInfo: { id: 'child-1' } }
+
+ // Act
+ render()
+
+ // Assert - check for focused class on label
+ const label = screen.getByTestId('slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
- })
- describe('Add Button', () => {
- it('should call handleAddNewChildChunk when Add button is clicked', () => {
- const handleAddNewChildChunk = vi.fn()
+ it('should not apply focused style when currChildChunk does not match', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } }
- render(
- ,
- )
+ // Act
+ render()
- const addButton = screen.getByText('Add')
- fireEvent.click(addButton)
-
- expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
- })
-
- it('should disable Add button when loading in full-doc mode', () => {
- mockParentMode.current = 'full-doc'
-
- render()
-
- const addButton = screen.getByText('Add')
- expect(addButton).toBeDisabled()
- })
-
- it('should stop propagation when Add button is clicked', () => {
- const handleAddNewChildChunk = vi.fn()
- const parentClickHandler = vi.fn()
-
- render(
-
-
-
,
- )
-
- const addButton = screen.getByText('Add')
- fireEvent.click(addButton)
-
- expect(handleAddNewChildChunk).toHaveBeenCalled()
- // Parent should not be called due to stopPropagation
+ // Assert
+ const label = screen.getByTestId('slice-label')
+ expect(label).not.toHaveClass('bg-state-accent-solid')
})
})
- describe('computeTotalInfo function', () => {
- it('should return search results when searching in full-doc mode', () => {
- mockParentMode.current = 'full-doc'
+ // Enabled/Disabled state
+ describe('Enabled State', () => {
+ it('should apply opacity when enabled is false', () => {
+ // Arrange & Act
+ const { container } = render()
- render()
-
- expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('opacity-50')
})
- it('should return "--" when total is 0 in full-doc mode', () => {
- mockParentMode.current = 'full-doc'
+ it('should not apply opacity when enabled is true', () => {
+ // Arrange & Act
+ const { container } = render()
- render()
-
- // When total is 0, displayText is '--'
- expect(screen.getByText(/--/)).toBeInTheDocument()
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).not.toHaveClass('opacity-50')
})
- it('should use childChunks length in paragraph mode', () => {
- mockParentMode.current = 'paragraph'
- const childChunks = [
- createMockChildChunk(),
- createMockChildChunk(),
- createMockChildChunk(),
- ]
+ it('should not apply opacity when focused is true even if enabled is false', () => {
+ // Arrange & Act
+ const { container } = render()
- render()
-
- expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
- })
- })
-
- describe('Focused State', () => {
- it('should not apply opacity when focused even if disabled', () => {
- const { container } = render(
- ,
- )
-
- const wrapper = container.firstChild
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-50')
})
})
- describe('Input clear button', () => {
- it('should call handleInputChange with empty string when clear is clicked', () => {
- mockParentMode.current = 'full-doc'
- const handleInputChange = vi.fn()
+ // Edited indicator
+ describe('Edited Indicator', () => {
+ it('should show edited indicator for edited chunks', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ const editedChunk = createMockChildChunk('child-edited', 'Edited content', true)
- render(
- ,
- )
+ // Act
+ render()
- // Find the clear button (it's the showClearIcon button in Input)
- const input = document.querySelector('input')
- if (input) {
- // Trigger clear by simulating the input's onClear
- const clearButton = document.querySelector('[class*="cursor-pointer"]')
- if (clearButton)
- fireEvent.click(clearButton)
- }
+ // Assert
+ const label = screen.getByTestId('slice-label')
+ expect(label.textContent).toContain('segment.edited')
+ })
+ })
+
+ // Multiple chunks
+ describe('Multiple Chunks', () => {
+ it('should render multiple child chunks', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ const chunks = [
+ createMockChildChunk('child-1', 'Content 1'),
+ createMockChildChunk('child-2', 'Content 2'),
+ createMockChildChunk('child-3', 'Content 3'),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('edit-slice')).toHaveLength(3)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty childChunks array', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ const { rerender } = render()
+
+ // Act
+ const newChunks = [createMockChildChunk('new-child', 'New content')]
+ rerender()
+
+ // Assert
+ expect(screen.getByText('New content')).toBeInTheDocument()
+ })
+
+ it('should disable add button when loading', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+
+ // Act
+ render()
+
+ // Assert
+ const addButton = screen.getByText(/operation\.add/i)
+ expect(addButton).toBeDisabled()
})
})
})
diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx
new file mode 100644
index 0000000000..a2fd94ee31
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx
@@ -0,0 +1,523 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+import { DocumentContext } from '../../context'
+import ActionButtons from './action-buttons'
+
+// Mock useKeyPress from ahooks to capture and test callback functions
+const mockUseKeyPress = vi.fn()
+vi.mock('ahooks', () => ({
+ useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => {
+ mockUseKeyPress(keys, callback, options)
+ },
+}))
+
+// Create wrapper component for providing context
+const createWrapper = (contextValue: {
+ docForm?: ChunkingMode
+ parentMode?: 'paragraph' | 'full-doc'
+}) => {
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+// Helper to get captured callbacks from useKeyPress mock
+const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => {
+ const escCall = mockUseKeyPress.mock.calls.find(
+ (call) => {
+ const keys = call[0]
+ return Array.isArray(keys) && keys.includes('esc')
+ },
+ )
+ return escCall?.[1]
+}
+
+const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => {
+ const ctrlSCall = mockUseKeyPress.mock.calls.find(
+ (call) => {
+ const keys = call[0]
+ return typeof keys === 'string' && keys.includes('.s')
+ },
+ )
+ return ctrlSCall?.[1]
+}
+
+describe('ActionButtons', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseKeyPress.mockClear()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render cancel button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ })
+
+ it('should render save button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
+ })
+
+ it('should render ESC keyboard hint on cancel button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText('ESC')).toBeInTheDocument()
+ })
+
+ it('should render S keyboard hint on save button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ expect(screen.getByText('S')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call handleCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockHandleCancel = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act
+ const cancelButton = screen.getAllByRole('button')[0]
+ fireEvent.click(cancelButton)
+
+ // Assert
+ expect(mockHandleCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call handleSave when save button is clicked', () => {
+ // Arrange
+ const mockHandleSave = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act
+ const buttons = screen.getAllByRole('button')
+ const saveButton = buttons[buttons.length - 1] // Save button is last
+ fireEvent.click(saveButton)
+
+ // Assert
+ expect(mockHandleSave).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable save button when loading is true', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ const saveButton = buttons[buttons.length - 1]
+ expect(saveButton).toBeDisabled()
+ })
+ })
+
+ // Regeneration button tests
+ describe('Regeneration Button', () => {
+ it('should show regeneration button in parent-child paragraph mode for edit action', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when isChildChunk is true', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when showRegenerationButton is false', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when actionType is add', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
+ })
+
+ it('should call handleRegeneration when regeneration button is clicked', () => {
+ // Arrange
+ const mockHandleRegeneration = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Act
+ const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
+ if (regenerationButton)
+ fireEvent.click(regenerationButton)
+
+ // Assert
+ expect(mockHandleRegeneration).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable regeneration button when loading is true', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert
+ const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
+ expect(regenerationButton).toBeDisabled()
+ })
+ })
+
+ // Default props tests
+ describe('Default Props', () => {
+ it('should use default actionType of edit', () => {
+ // Arrange & Act - when not specifying actionType and other conditions are met
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert - regeneration button should show with default actionType='edit'
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+
+ it('should use default isChildChunk of false', () => {
+ // Arrange & Act - when not specifying isChildChunk
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert - regeneration button should show with default isChildChunk=false
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+
+ it('should use default showRegenerationButton of true', () => {
+ // Arrange & Act - when not specifying showRegenerationButton
+ render(
+ ,
+ { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
+ )
+
+ // Assert - regeneration button should show with default showRegenerationButton=true
+ expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle missing context values gracefully', () => {
+ // Arrange & Act & Assert - should not throw
+ expect(() => {
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+ }).not.toThrow()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
+ })
+ })
+
+ // Keyboard shortcuts tests via useKeyPress callbacks
+ describe('Keyboard Shortcuts', () => {
+ it('should display ctrl key hint on save button', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert - check for ctrl key hint (Ctrl or Cmd depending on system)
+ const kbdElements = document.querySelectorAll('.system-kbd')
+ expect(kbdElements.length).toBeGreaterThan(0)
+ })
+
+ it('should call handleCancel and preventDefault when ESC key is pressed', () => {
+ // Arrange
+ const mockHandleCancel = vi.fn()
+ const mockPreventDefault = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act - get the ESC callback and invoke it
+ const escCallback = getEscCallback()
+ expect(escCallback).toBeDefined()
+ escCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
+
+ // Assert
+ expect(mockPreventDefault).toHaveBeenCalledTimes(1)
+ expect(mockHandleCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => {
+ // Arrange
+ const mockHandleSave = vi.fn()
+ const mockPreventDefault = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act - get the Ctrl+S callback and invoke it
+ const ctrlSCallback = getCtrlSCallback()
+ expect(ctrlSCallback).toBeDefined()
+ ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
+
+ // Assert
+ expect(mockPreventDefault).toHaveBeenCalledTimes(1)
+ expect(mockHandleSave).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call handleSave when Ctrl+S is pressed while loading', () => {
+ // Arrange
+ const mockHandleSave = vi.fn()
+ const mockPreventDefault = vi.fn()
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Act - get the Ctrl+S callback and invoke it
+ const ctrlSCallback = getCtrlSCallback()
+ expect(ctrlSCallback).toBeDefined()
+ ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
+
+ // Assert
+ expect(mockPreventDefault).toHaveBeenCalledTimes(1)
+ expect(mockHandleSave).not.toHaveBeenCalled()
+ })
+
+ it('should register useKeyPress with correct options for Ctrl+S', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper({}) },
+ )
+
+ // Assert - verify useKeyPress was called with correct options
+ const ctrlSCall = mockUseKeyPress.mock.calls.find(
+ call => typeof call[0] === 'string' && call[0].includes('.s'),
+ )
+ expect(ctrlSCall).toBeDefined()
+ expect(ctrlSCall![2]).toEqual({ exactMatch: true, useCapture: true })
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx
new file mode 100644
index 0000000000..6f76fb4f79
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx
@@ -0,0 +1,194 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AddAnother from './add-another'
+
+describe('AddAnother', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the checkbox', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - Checkbox component renders with shrink-0 class
+ const checkbox = container.querySelector('.shrink-0')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should render the add another text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format
+ expect(screen.getByText(/segment\.addAnother/i)).toBeInTheDocument()
+ })
+
+ it('should render with correct base styling classes', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('gap-x-1')
+ expect(wrapper).toHaveClass('pl-1')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render unchecked state when isChecked is false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - unchecked checkbox has border class
+ const checkbox = container.querySelector('.border-components-checkbox-border')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should render checked state when isChecked is true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - checked checkbox has bg-components-checkbox-bg class
+ const checkbox = container.querySelector('.bg-components-checkbox-bg')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCheck when checkbox is clicked', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act - click on the checkbox element
+ const checkbox = container.querySelector('.shrink-0')
+ if (checkbox)
+ fireEvent.click(checkbox)
+
+ // Assert
+ expect(mockOnCheck).toHaveBeenCalledTimes(1)
+ })
+
+ it('should toggle checked state on multiple clicks', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { container, rerender } = render(
+ ,
+ )
+
+ // Act - first click
+ const checkbox = container.querySelector('.shrink-0')
+ if (checkbox) {
+ fireEvent.click(checkbox)
+ rerender()
+ fireEvent.click(checkbox)
+ }
+
+ // Assert
+ expect(mockOnCheck).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render text with tertiary text color', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const textElement = container.querySelector('.text-text-tertiary')
+ expect(textElement).toBeInTheDocument()
+ })
+
+ it('should render text with xs medium font styling', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const textElement = container.querySelector('.system-xs-medium')
+ expect(textElement).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { rerender, container } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ const checkbox = container.querySelector('.shrink-0')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should handle rapid state changes', () => {
+ // Arrange
+ const mockOnCheck = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act
+ const checkbox = container.querySelector('.shrink-0')
+ if (checkbox) {
+ for (let i = 0; i < 5; i++)
+ fireEvent.click(checkbox)
+ }
+
+ // Assert
+ expect(mockOnCheck).toHaveBeenCalledTimes(5)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx
new file mode 100644
index 0000000000..0c0190ed5d
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx
@@ -0,0 +1,277 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import BatchAction from './batch-action'
+
+describe('BatchAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ selectedIds: ['1', '2', '3'],
+ onBatchEnable: vi.fn(),
+ onBatchDisable: vi.fn(),
+ onBatchDelete: vi.fn().mockResolvedValue(undefined),
+ onCancel: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should display selected count', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+
+ it('should render enable button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument()
+ })
+
+ it('should render disable button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument()
+ })
+
+ it('should render delete button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument()
+ })
+
+ it('should render cancel button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onBatchEnable when enable button is clicked', () => {
+ // Arrange
+ const mockOnBatchEnable = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.enable/i))
+
+ // Assert
+ expect(mockOnBatchEnable).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onBatchDisable when disable button is clicked', () => {
+ // Arrange
+ const mockOnBatchDisable = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.disable/i))
+
+ // Assert
+ expect(mockOnBatchDisable).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.cancel/i))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should show delete confirmation dialog when delete button is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.delete/i))
+
+ // Assert - Confirm dialog should appear
+ expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
+ })
+
+ it('should call onBatchDelete when confirm is clicked in delete dialog', async () => {
+ // Arrange
+ const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ // Act - open delete dialog
+ fireEvent.click(screen.getByText(/batchAction\.delete/i))
+
+ // Act - click confirm
+ const confirmButton = screen.getByText(/operation\.sure/i)
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockOnBatchDelete).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+
+ // Optional props tests
+ describe('Optional Props', () => {
+ it('should render download button when onBatchDownload is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument()
+ })
+
+ it('should not render download button when onBatchDownload is not provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument()
+ })
+
+ it('should render archive button when onArchive is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument()
+ })
+
+ it('should render metadata button when onEditMetadata is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
+ })
+
+ it('should render re-index button when onBatchReIndex is provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument()
+ })
+
+ it('should call onBatchDownload when download button is clicked', () => {
+ // Arrange
+ const mockOnBatchDownload = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.download/i))
+
+ // Assert
+ expect(mockOnBatchDownload).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onArchive when archive button is clicked', () => {
+ // Arrange
+ const mockOnArchive = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.archive/i))
+
+ // Assert
+ expect(mockOnArchive).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onEditMetadata when metadata button is clicked', () => {
+ // Arrange
+ const mockOnEditMetadata = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/metadata\.metadata/i))
+
+ // Assert
+ expect(mockOnEditMetadata).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onBatchReIndex when re-index button is clicked', () => {
+ // Arrange
+ const mockOnBatchReIndex = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText(/batchAction\.reIndex/i))
+
+ // Assert
+ expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+ })
+
+ // Selected count display tests
+ describe('Selected Count', () => {
+ it('should display correct count for single selection', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('should display correct count for multiple selections', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('2')).toBeInTheDocument()
+ })
+
+ it('should handle empty selectedIds array', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx
new file mode 100644
index 0000000000..01c1be919c
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx
@@ -0,0 +1,317 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+import ChunkContent from './chunk-content'
+
+// Mock ResizeObserver
+const OriginalResizeObserver = globalThis.ResizeObserver
+class MockResizeObserver {
+ observe = vi.fn()
+ disconnect = vi.fn()
+ unobserve = vi.fn()
+}
+
+beforeAll(() => {
+ globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver
+})
+
+afterAll(() => {
+ globalThis.ResizeObserver = OriginalResizeObserver
+})
+
+describe('ChunkContent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ question: 'Test question content',
+ onQuestionChange: vi.fn(),
+ docForm: ChunkingMode.text,
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render textarea in edit mode with text docForm', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toBeInTheDocument()
+ })
+
+ it('should render Markdown content in view mode with text docForm', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - In view mode, textarea should not be present, Markdown renders instead
+ expect(container.querySelector('textarea')).not.toBeInTheDocument()
+ })
+ })
+
+ // QA mode tests
+ describe('QA Mode', () => {
+ it('should render QA layout when docForm is qa', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - QA mode has QUESTION and ANSWER labels
+ expect(screen.getByText('QUESTION')).toBeInTheDocument()
+ expect(screen.getByText('ANSWER')).toBeInTheDocument()
+ })
+
+ it('should display question value in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas[0]).toHaveValue('My question')
+ })
+
+ it('should display answer value in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas[1]).toHaveValue('My answer')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onQuestionChange when textarea value changes in text mode', () => {
+ // Arrange
+ const mockOnQuestionChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ const textarea = screen.getByRole('textbox')
+ fireEvent.change(textarea, { target: { value: 'New content' } })
+
+ // Assert
+ expect(mockOnQuestionChange).toHaveBeenCalledWith('New content')
+ })
+
+ it('should call onQuestionChange when question textarea changes in QA mode', () => {
+ // Arrange
+ const mockOnQuestionChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ const textareas = screen.getAllByRole('textbox')
+ fireEvent.change(textareas[0], { target: { value: 'New question' } })
+
+ // Assert
+ expect(mockOnQuestionChange).toHaveBeenCalledWith('New question')
+ })
+
+ it('should call onAnswerChange when answer textarea changes in QA mode', () => {
+ // Arrange
+ const mockOnAnswerChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ const textareas = screen.getAllByRole('textbox')
+ fireEvent.change(textareas[1], { target: { value: 'New answer' } })
+
+ // Assert
+ expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer')
+ })
+
+ it('should disable textarea when isEditMode is false in text mode', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - In view mode, Markdown is rendered instead of textarea
+ expect(container.querySelector('textarea')).not.toBeInTheDocument()
+ })
+
+ it('should disable textareas when isEditMode is false in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ textareas.forEach((textarea) => {
+ expect(textarea).toBeDisabled()
+ })
+ })
+ })
+
+ // DocForm variations
+ describe('DocForm Variations', () => {
+ it('should handle ChunkingMode.text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should handle ChunkingMode.qa', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - QA mode should show both question and answer
+ expect(screen.getByText('QUESTION')).toBeInTheDocument()
+ expect(screen.getByText('ANSWER')).toBeInTheDocument()
+ })
+
+ it('should handle ChunkingMode.parentChild similar to text mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - parentChild should render like text mode
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty question', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('')
+ })
+
+ it('should handle empty answer in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ const textareas = screen.getAllByRole('textbox')
+ expect(textareas[1]).toHaveValue('')
+ })
+
+ it('should handle undefined answer in QA mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - should render without crashing
+ expect(screen.getByText('QUESTION')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender(
+ ,
+ )
+
+ // Assert
+ const textarea = screen.getByRole('textbox')
+ expect(textarea).toHaveValue('Updated')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx
new file mode 100644
index 0000000000..af8c981bf5
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Dot from './dot'
+
+describe('Dot', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the dot character', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('·')).toBeInTheDocument()
+ })
+
+ it('should render with correct styling classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const dotElement = container.firstChild as HTMLElement
+ expect(dotElement).toHaveClass('system-xs-medium')
+ expect(dotElement).toHaveClass('text-text-quaternary')
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('·')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx
index bf3a2c91e5..6feb9ea4c0 100644
--- a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx
@@ -1,129 +1,153 @@
import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import Empty from './empty'
-// Mock react-i18next
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => {
- if (key === 'segment.empty')
- return 'No results found'
- if (key === 'segment.clearFilter')
- return 'Clear Filter'
- return key
- },
- }),
-}))
-
-describe('Empty Component', () => {
- const defaultProps = {
- onClearFilter: vi.fn(),
- }
-
+describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
})
+ // Rendering tests
describe('Rendering', () => {
- it('should render empty state message', () => {
- render()
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
- expect(screen.getByText('No results found')).toBeInTheDocument()
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the file list icon', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - RiFileList2Line icon should be rendered
+ const icon = container.querySelector('.h-6.w-6')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render empty message text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - i18n key format: datasetDocuments:segment.empty
+ expect(screen.getByText(/segment\.empty/i)).toBeInTheDocument()
})
it('should render clear filter button', () => {
- render()
+ // Arrange & Act
+ render()
- expect(screen.getByText('Clear Filter')).toBeInTheDocument()
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
})
- it('should render icon', () => {
- const { container } = render()
+ it('should render background empty cards', () => {
+ // Arrange & Act
+ const { container } = render()
- // Check for the icon container
+ // Assert - should have 10 background cards
+ const emptyCards = container.querySelectorAll('.bg-background-section-burn')
+ expect(emptyCards).toHaveLength(10)
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onClearFilter when clear filter button is clicked', () => {
+ // Arrange
+ const mockOnClearFilter = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render the decorative lines', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - there should be 4 Line components (SVG elements)
+ const svgElements = container.querySelectorAll('svg')
+ expect(svgElements.length).toBeGreaterThanOrEqual(4)
+ })
+
+ it('should render mask overlay', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render icon container with proper styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
const iconContainer = container.querySelector('.shadow-lg')
expect(iconContainer).toBeInTheDocument()
})
- it('should render decorative lines', () => {
- const { container } = render()
+ it('should render clear filter button with accent text styling', () => {
+ // Arrange & Act
+ render()
- // Check for SVG lines
- const svgs = container.querySelectorAll('svg')
- expect(svgs.length).toBeGreaterThan(0)
- })
-
- it('should render background cards', () => {
- const { container } = render()
-
- // Check for background empty cards (10 of them)
- const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
- expect(backgroundCards.length).toBe(10)
- })
-
- it('should render mask overlay', () => {
- const { container } = render()
-
- const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
- expect(maskOverlay).toBeInTheDocument()
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('text-text-accent')
})
})
- describe('Interactions', () => {
- it('should call onClearFilter when clear filter button is clicked', () => {
- const onClearFilter = vi.fn()
+ // Props tests
+ describe('Props', () => {
+ it('should accept onClearFilter callback prop', () => {
+ // Arrange
+ const mockCallback = vi.fn()
- render()
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
- const clearButton = screen.getByText('Clear Filter')
- fireEvent.click(clearButton)
-
- expect(onClearFilter).toHaveBeenCalledTimes(1)
+ // Assert
+ expect(mockCallback).toHaveBeenCalled()
})
})
- describe('Memoization', () => {
- it('should be memoized', () => {
- // Empty is wrapped with React.memo
- const { rerender } = render()
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle multiple clicks on clear filter button', () => {
+ // Arrange
+ const mockOnClearFilter = vi.fn()
+ render()
- // Same props should not cause re-render issues
- rerender()
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
- expect(screen.getByText('No results found')).toBeInTheDocument()
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalledTimes(3)
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const emptyCards = container.querySelectorAll('.bg-background-section-burn')
+ expect(emptyCards).toHaveLength(10)
})
})
})
-
-describe('EmptyCard Component', () => {
- it('should render within Empty component', () => {
- const { container } = render()
-
- // EmptyCard renders as background cards
- const emptyCards = container.querySelectorAll('.h-32.w-full')
- expect(emptyCards.length).toBe(10)
- })
-
- it('should have correct opacity', () => {
- const { container } = render()
-
- const emptyCards = container.querySelectorAll('.opacity-30')
- expect(emptyCards.length).toBe(10)
- })
-})
-
-describe('Line Component', () => {
- it('should render SVG lines within Empty component', () => {
- const { container } = render()
-
- // Line components render as SVG elements (4 Line components + 1 icon SVG)
- const lines = container.querySelectorAll('svg')
- expect(lines.length).toBeGreaterThanOrEqual(4)
- })
-
- it('should have gradient definition', () => {
- const { container } = render()
-
- const gradients = container.querySelectorAll('linearGradient')
- expect(gradients.length).toBeGreaterThan(0)
- })
-})
diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx
new file mode 100644
index 0000000000..24def69f7a
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx
@@ -0,0 +1,262 @@
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import FullScreenDrawer from './full-screen-drawer'
+
+// Mock the Drawer component since it has high complexity
+vi.mock('./drawer', () => ({
+ default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
+ if (!open)
+ return null
+ return (
+
+ {children}
+
+ )
+ },
+}))
+
+describe('FullScreenDrawer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing when open', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
+ })
+
+ it('should not render when closed', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
+ })
+
+ it('should render children content', () => {
+ // Arrange & Act
+ render(
+
+ Test Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Test Content')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should pass fullScreen=true to Drawer with full width class', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
+ })
+
+ it('should pass fullScreen=false to Drawer with fixed width class', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
+ })
+
+ it('should pass showOverlay prop with default true', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-show-overlay')).toBe('true')
+ })
+
+ it('should pass showOverlay=false when specified', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-show-overlay')).toBe('false')
+ })
+
+ it('should pass needCheckChunks prop with default false', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
+ })
+
+ it('should pass needCheckChunks=true when specified', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
+ })
+
+ it('should pass modal prop with default false', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-modal')).toBe('false')
+ })
+
+ it('should pass modal=true when specified', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ expect(drawer.getAttribute('data-modal')).toBe('true')
+ })
+ })
+
+ // Styling tests
+ describe('Styling', () => {
+ it('should apply panel content classes for non-fullScreen mode', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ const contentClass = drawer.getAttribute('data-panel-content-class')
+ expect(contentClass).toContain('bg-components-panel-bg')
+ expect(contentClass).toContain('rounded-xl')
+ })
+
+ it('should apply panel content classes without border for fullScreen mode', () => {
+ // Arrange & Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const drawer = screen.getByTestId('drawer-mock')
+ const contentClass = drawer.getAttribute('data-panel-content-class')
+ expect(contentClass).toContain('bg-components-panel-bg')
+ expect(contentClass).not.toContain('rounded-xl')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined onClose gracefully', () => {
+ // Arrange & Act & Assert - should not throw
+ expect(() => {
+ render(
+
+ Content
+ ,
+ )
+ }).not.toThrow()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+
+ Content
+ ,
+ )
+
+ // Act
+ rerender(
+
+ Updated Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Updated Content')).toBeInTheDocument()
+ })
+
+ it('should handle toggle between open and closed states', () => {
+ // Arrange
+ const { rerender } = render(
+
+ Content
+ ,
+ )
+ expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
+
+ // Act
+ rerender(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx
new file mode 100644
index 0000000000..a11f98e3bb
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx
@@ -0,0 +1,317 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Keywords from './keywords'
+
+describe('Keywords', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the keywords label', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - i18n key format
+ expect(screen.getByText(/segment\.keywords/i)).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('flex-col')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should display dash when no keywords and actionType is view', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+
+ it('should not display dash when actionType is edit', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ })
+
+ it('should not display dash when actionType is add', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ it('should use default actionType of view', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - dash should appear in view mode with empty keywords
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render label with uppercase styling', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const labelElement = container.querySelector('.system-xs-medium-uppercase')
+ expect(labelElement).toBeInTheDocument()
+ })
+
+ it('should render keywords container with overflow handling', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const keywordsContainer = container.querySelector('.overflow-auto')
+ expect(keywordsContainer).toBeInTheDocument()
+ })
+
+ it('should render keywords container with max height', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const keywordsContainer = container.querySelector('.max-h-\\[200px\\]')
+ expect(keywordsContainer).toBeInTheDocument()
+ })
+ })
+
+ // Edit mode tests
+ describe('Edit Mode', () => {
+ it('should render TagInput component when keywords exist', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - TagInput should be rendered instead of dash
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ expect(container.querySelector('.flex-wrap')).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty keywords array in view mode without segInfo keywords', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - container should be rendered
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render(
+ ,
+ )
+
+ // Act
+ rerender(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle segInfo with undefined keywords showing dash in view mode', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert - dash should show because segInfo.keywords is undefined/empty
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+ })
+
+ // TagInput callback tests
+ describe('TagInput Callback', () => {
+ it('should call onKeywordsChange when keywords are modified', () => {
+ // Arrange
+ const mockOnKeywordsChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Assert - TagInput should be rendered
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
+ })
+
+ it('should disable add when isEditMode is false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - TagInput should exist but with disabled add
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should disable remove when only one keyword exists in edit mode', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - component should render
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should allow remove when multiple keywords exist in edit mode', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - component should render
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx
new file mode 100644
index 0000000000..bd46dfdd62
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx
@@ -0,0 +1,327 @@
+import type { ReactNode } from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter'
+import RegenerationModal from './regeneration-modal'
+
+// Store emit function for triggering events in tests
+let emitFunction: ((v: string) => void) | null = null
+
+const EmitCapture = () => {
+ const { eventEmitter } = useEventEmitterContextContext()
+ emitFunction = eventEmitter?.emit?.bind(eventEmitter) || null
+ return null
+}
+
+// Custom wrapper that captures emit function
+const TestWrapper = ({ children }: { children: ReactNode }) => {
+ return (
+
+
+ {children}
+
+ )
+}
+
+// Create a wrapper component with event emitter context
+const createWrapper = () => {
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('RegenerationModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ isShow: true,
+ onConfirm: vi.fn(),
+ onCancel: vi.fn(),
+ onClose: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing when isShow is true', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+ })
+
+ it('should not render content when isShow is false', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert - Modal container might exist but content should not be visible
+ expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
+ })
+
+ it('should render confirmation message', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument()
+ })
+
+ it('should render cancel button in default state', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ })
+
+ it('should render regenerate button in default state', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when cancel button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ fireEvent.click(screen.getByText(/operation\.cancel/i))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onConfirm when regenerate button is clicked', () => {
+ // Arrange
+ const mockOnConfirm = vi.fn()
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ fireEvent.click(screen.getByText(/operation\.regenerate/i))
+
+ // Assert
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Modal content states - these would require event emitter manipulation
+ describe('Modal States', () => {
+ it('should show default content initially', () => {
+ // Arrange & Act
+ render(, { wrapper: createWrapper() })
+
+ // Assert
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+ expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle toggling isShow prop', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
+ })
+
+ it('should maintain handlers when rerendered', () => {
+ // Arrange
+ const mockOnConfirm = vi.fn()
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+ fireEvent.click(screen.getByText(/operation\.regenerate/i))
+
+ // Assert
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Loading state
+ describe('Loading State', () => {
+ it('should show regenerating content when update-segment event is emitted', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ act(() => {
+ if (emitFunction)
+ emitFunction('update-segment')
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/segment\.regeneratingTitle/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should show regenerating message during loading', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ act(() => {
+ if (emitFunction)
+ emitFunction('update-segment')
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/segment\.regeneratingMessage/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should disable regenerate button during loading', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ act(() => {
+ if (emitFunction)
+ emitFunction('update-segment')
+ })
+
+ // Assert
+ await waitFor(() => {
+ const button = screen.getByText(/operation\.regenerate/i).closest('button')
+ expect(button).toBeDisabled()
+ })
+ })
+ })
+
+ // Success state
+ describe('Success State', () => {
+ it('should show success content when update-segment-success event is emitted followed by done', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act - trigger loading then success then done
+ act(() => {
+ if (emitFunction) {
+ emitFunction('update-segment')
+ emitFunction('update-segment-success')
+ emitFunction('update-segment-done')
+ }
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/segment\.regenerationSuccessTitle/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should show success message when completed', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ act(() => {
+ if (emitFunction) {
+ emitFunction('update-segment')
+ emitFunction('update-segment-success')
+ emitFunction('update-segment-done')
+ }
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/segment\.regenerationSuccessMessage/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should show close button with countdown in success state', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ act(() => {
+ if (emitFunction) {
+ emitFunction('update-segment')
+ emitFunction('update-segment-success')
+ emitFunction('update-segment-done')
+ }
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should call onClose when close button is clicked in success state', async () => {
+ // Arrange
+ const mockOnClose = vi.fn()
+ render(, { wrapper: createWrapper() })
+
+ // Act
+ act(() => {
+ if (emitFunction) {
+ emitFunction('update-segment')
+ emitFunction('update-segment-success')
+ emitFunction('update-segment-done')
+ }
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByText(/operation\.close/i))
+
+ // Assert
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+ })
+
+ // State transitions
+ describe('State Transitions', () => {
+ it('should return to default content when update fails (no success event)', async () => {
+ // Arrange
+ render(, { wrapper: createWrapper() })
+
+ // Act - trigger loading then done without success
+ act(() => {
+ if (emitFunction) {
+ emitFunction('update-segment')
+ emitFunction('update-segment-done')
+ }
+ })
+
+ // Assert - should show default content
+ await waitFor(() => {
+ expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx
new file mode 100644
index 0000000000..8d0bf89636
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx
@@ -0,0 +1,215 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import SegmentIndexTag from './segment-index-tag'
+
+describe('SegmentIndexTag', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the Chunk icon', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const icon = container.querySelector('.h-3.w-3')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render position ID with default prefix', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - default prefix is 'Chunk'
+ expect(screen.getByText('Chunk-05')).toBeInTheDocument()
+ })
+
+ it('should render position ID without padding for two-digit numbers', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-15')).toBeInTheDocument()
+ })
+
+ it('should render position ID without padding for three-digit numbers', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-123')).toBeInTheDocument()
+ })
+
+ it('should render custom label when provided', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Custom Label')).toBeInTheDocument()
+ })
+
+ it('should use custom labelPrefix', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Segment-03')).toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ it('should apply custom iconClassName', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const icon = container.querySelector('.custom-icon-class')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should apply custom labelClassName', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const label = container.querySelector('.custom-label-class')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should handle string positionId', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-07')).toBeInTheDocument()
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should compute localPositionId based on positionId and labelPrefix', () => {
+ // Arrange & Act
+ const { rerender } = render()
+ expect(screen.getByText('Chunk-01')).toBeInTheDocument()
+
+ // Act - change positionId
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Chunk-02')).toBeInTheDocument()
+ })
+
+ it('should update when labelPrefix changes', () => {
+ // Arrange & Act
+ const { rerender } = render()
+ expect(screen.getByText('Chunk-01')).toBeInTheDocument()
+
+ // Act - change labelPrefix
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Part-01')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render icon with tertiary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const icon = container.querySelector('.text-text-tertiary')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render label with xs medium font styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const label = container.querySelector('.system-xs-medium')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should render icon with margin-right spacing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const icon = container.querySelector('.mr-0\\.5')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle positionId of 0', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Chunk-00')).toBeInTheDocument()
+ })
+
+ it('should handle undefined positionId', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - should display 'Chunk-undefined' or similar
+ expect(screen.getByText(/Chunk-/)).toBeInTheDocument()
+ })
+
+ it('should prioritize label over computed positionId', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Override')).toBeInTheDocument()
+ expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx
new file mode 100644
index 0000000000..8456652126
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx
@@ -0,0 +1,151 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Tag from './tag'
+
+describe('Tag', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the hash symbol', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('#')).toBeInTheDocument()
+ })
+
+ it('should render the text content', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('keyword')).toBeInTheDocument()
+ })
+
+ it('should render with correct base styling classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const tagElement = container.firstChild as HTMLElement
+ expect(tagElement).toHaveClass('inline-flex')
+ expect(tagElement).toHaveClass('items-center')
+ expect(tagElement).toHaveClass('gap-x-0.5')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const tagElement = container.firstChild as HTMLElement
+ expect(tagElement).toHaveClass('custom-class')
+ })
+
+ it('should render different text values', () => {
+ // Arrange & Act
+ const { rerender } = render()
+ expect(screen.getByText('first')).toBeInTheDocument()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('second')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render hash with quaternary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const hashSpan = container.querySelector('.text-text-quaternary')
+ expect(hashSpan).toBeInTheDocument()
+ expect(hashSpan).toHaveTextContent('#')
+ })
+
+ it('should render text with tertiary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const textSpan = container.querySelector('.text-text-tertiary')
+ expect(textSpan).toBeInTheDocument()
+ expect(textSpan).toHaveTextContent('test')
+ })
+
+ it('should have truncate class for text overflow', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const textSpan = container.querySelector('.truncate')
+ expect(textSpan).toBeInTheDocument()
+ })
+
+ it('should have max-width constraint on text', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const textSpan = container.querySelector('.max-w-12')
+ expect(textSpan).toBeInTheDocument()
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently with same props', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - should still render the hash symbol
+ expect(screen.getByText('#')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('test-tag_1')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('#')).toBeInTheDocument()
+ expect(screen.getByText('test')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx b/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx
new file mode 100644
index 0000000000..e1004b1454
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx
@@ -0,0 +1,130 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import DisplayToggle from './display-toggle'
+
+describe('DisplayToggle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render button with proper styling', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('flex')
+ expect(button).toHaveClass('items-center')
+ expect(button).toHaveClass('justify-center')
+ expect(button).toHaveClass('rounded-lg')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should render expand icon when isCollapsed is true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - RiLineHeight icon for expand
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render collapse icon when isCollapsed is false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - Collapse icon
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call toggleCollapsed when button is clicked', () => {
+ // Arrange
+ const mockToggle = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(mockToggle).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call toggleCollapsed on multiple clicks', () => {
+ // Arrange
+ const mockToggle = vi.fn()
+ render()
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ // Assert
+ expect(mockToggle).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ // Tooltip tests
+ describe('Tooltip', () => {
+ it('should render with tooltip wrapper', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert - Tooltip renders a wrapper around button
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should toggle icon when isCollapsed prop changes', () => {
+ // Arrange
+ const { rerender, container } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert - icon should still be present
+ const icon = container.querySelector('.h-4.w-4')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx
new file mode 100644
index 0000000000..8e936a2c4a
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx
@@ -0,0 +1,507 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import NewChildSegmentModal from './new-child-segment'
+
+// Mock next/navigation
+vi.mock('next/navigation', () => ({
+ useParams: () => ({
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ }),
+}))
+
+// Mock ToastContext
+const mockNotify = vi.fn()
+vi.mock('use-context-selector', async (importOriginal) => {
+ const actual = await importOriginal() as Record
+ return {
+ ...actual,
+ useContext: () => ({ notify: mockNotify }),
+ }
+})
+
+// Mock document context
+let mockParentMode = 'paragraph'
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
+ return selector({ parentMode: mockParentMode })
+ },
+}))
+
+// Mock segment list context
+let mockFullScreen = false
+const mockToggleFullScreen = vi.fn()
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
+ const state = {
+ fullScreen: mockFullScreen,
+ toggleFullScreen: mockToggleFullScreen,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock useAddChildSegment
+const mockAddChildSegment = vi.fn()
+vi.mock('@/service/knowledge/use-segment', () => ({
+ useAddChildSegment: () => ({
+ mutateAsync: mockAddChildSegment,
+ }),
+}))
+
+// Mock app store
+vi.mock('@/app/components/app/store', () => ({
+ useStore: () => ({ appSidebarExpand: 'expand' }),
+}))
+
+// Mock child components
+vi.mock('./common/action-buttons', () => ({
+ default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => (
+
+
+
+ {actionType}
+ {isChildChunk ? 'true' : 'false'}
+
+ ),
+}))
+
+vi.mock('./common/add-another', () => ({
+ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => (
+
+
+
+ ),
+}))
+
+vi.mock('./common/chunk-content', () => ({
+ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
+
+ onQuestionChange(e.target.value)}
+ />
+ {isEditMode ? 'editing' : 'viewing'}
+
+ ),
+}))
+
+vi.mock('./common/dot', () => ({
+ default: () => •,
+}))
+
+vi.mock('./common/segment-index-tag', () => ({
+ SegmentIndexTag: ({ label }: { label: string }) => {label},
+}))
+
+describe('NewChildSegmentModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFullScreen = false
+ mockParentMode = 'paragraph'
+ })
+
+ const defaultProps = {
+ chunkId: 'chunk-1',
+ onCancel: vi.fn(),
+ onSave: vi.fn(),
+ viewNewlyAddedChildChunk: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render add child chunk title', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.addChildChunk/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk content component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag with new child chunk label', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+
+ it('should render add another checkbox', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('add-another')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // Act
+ const closeButtons = container.querySelectorAll('.cursor-pointer')
+ if (closeButtons.length > 1)
+ fireEvent.click(closeButtons[1])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call toggleFullScreen when expand button is clicked', () => {
+ // Arrange
+ const { container } = render()
+
+ // Act
+ const expandButtons = container.querySelectorAll('.cursor-pointer')
+ if (expandButtons.length > 0)
+ fireEvent.click(expandButtons[0])
+
+ // Assert
+ expect(mockToggleFullScreen).toHaveBeenCalled()
+ })
+
+ it('should update content when input changes', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'New content' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('content-input')).toHaveValue('New content')
+ })
+
+ it('should toggle add another checkbox', () => {
+ // Arrange
+ render()
+ const checkbox = screen.getByTestId('add-another-checkbox')
+
+ // Act
+ fireEvent.click(checkbox)
+
+ // Assert
+ expect(checkbox).toBeInTheDocument()
+ })
+ })
+
+ // Save validation
+ describe('Save Validation', () => {
+ it('should show error when content is empty', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+ })
+
+ // Successful save
+ describe('Successful Save', () => {
+ it('should call addChildSegment when valid content is provided', async () => {
+ // Arrange
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockAddChildSegment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ datasetId: 'test-dataset-id',
+ documentId: 'test-document-id',
+ segmentId: 'chunk-1',
+ body: expect.objectContaining({
+ content: 'Valid content',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should show success notification after save', async () => {
+ // Arrange
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ }),
+ )
+ })
+ })
+ })
+
+ // Full screen mode
+ describe('Full Screen Mode', () => {
+ it('should show action buttons in header when fullScreen', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should show add another in header when fullScreen', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('add-another')).toBeInTheDocument()
+ })
+ })
+
+ // Props
+ describe('Props', () => {
+ it('should pass actionType add to ActionButtons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-type')).toHaveTextContent('add')
+ })
+
+ it('should pass isChildChunk true to ActionButtons', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
+ })
+
+ it('should pass isEditMode true to ChunkContent', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined viewNewlyAddedChildChunk', () => {
+ // Arrange
+ const props = { ...defaultProps, viewNewlyAddedChildChunk: undefined }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+ })
+
+ // Add another behavior
+ describe('Add Another Behavior', () => {
+ it('should close modal when add another is unchecked after save', async () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+
+ // Uncheck add another
+ fireEvent.click(screen.getByTestId('add-another-checkbox'))
+
+ // Enter valid content
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert - modal should close
+ await waitFor(() => {
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+ })
+
+ it('should not close modal when add another is checked after save', async () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+
+ // Enter valid content (add another is checked by default)
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert - modal should not close, only content cleared
+ await waitFor(() => {
+ expect(screen.getByTestId('content-input')).toHaveValue('')
+ })
+ })
+ })
+
+ // View newly added chunk
+ describe('View Newly Added Chunk', () => {
+ it('should show custom button in full-doc mode after save', async () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+
+ // Enter valid content
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert - success notification with custom component
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ customComponent: expect.anything(),
+ }),
+ )
+ })
+ })
+
+ it('should not show custom button in paragraph mode after save', async () => {
+ // Arrange
+ mockParentMode = 'paragraph'
+ const mockOnSave = vi.fn()
+ mockAddChildSegment.mockImplementation((_params, options) => {
+ options.onSuccess({ data: { id: 'new-child-id' } })
+ options.onSettled()
+ return Promise.resolve()
+ })
+
+ render()
+
+ // Enter valid content
+ fireEvent.change(screen.getByTestId('content-input'), {
+ target: { value: 'Valid content' },
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert - onSave should be called with data
+ await waitFor(() => {
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-child-id' }))
+ })
+ })
+ })
+
+ // Cancel behavior
+ describe('Cancel Behavior', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('cancel-btn'))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx
new file mode 100644
index 0000000000..570d93d390
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx
@@ -0,0 +1,270 @@
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import { noop } from 'es-toolkit/function'
+import { createContext, useContextSelector } from 'use-context-selector'
+import { describe, expect, it, vi } from 'vitest'
+
+import ChunkContent from './chunk-content'
+
+// Create mock context matching the actual SegmentListContextValue
+type SegmentListContextValue = {
+ isCollapsed: boolean
+ fullScreen: boolean
+ toggleFullScreen: (fullscreen?: boolean) => void
+ currSegment: { showModal: boolean }
+ currChildChunk: { showModal: boolean }
+}
+
+const MockSegmentListContext = createContext({
+ isCollapsed: true,
+ fullScreen: false,
+ toggleFullScreen: noop,
+ currSegment: { showModal: false },
+ currChildChunk: { showModal: false },
+})
+
+// Mock the context module
+vi.mock('..', () => ({
+ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => {
+ return useContextSelector(MockSegmentListContext, selector)
+ },
+}))
+
+// Helper to create wrapper with context
+const createWrapper = (isCollapsed: boolean = true) => {
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+describe('ChunkContent', () => {
+ const defaultDetail = {
+ content: 'Test content',
+ sign_content: 'Test sign content',
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render content in non-QA mode', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - should render without Q and A labels
+ expect(container.textContent).not.toContain('Q')
+ expect(container.textContent).not.toContain('A')
+ })
+ })
+
+ // QA mode tests
+ describe('QA Mode', () => {
+ it('should render Q and A labels when answer is present', () => {
+ // Arrange
+ const qaDetail = {
+ content: 'Question content',
+ sign_content: 'Sign content',
+ answer: 'Answer content',
+ }
+
+ // Act
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+
+ it('should not render Q and A labels when answer is undefined', () => {
+ // Arrange & Act
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should apply custom className', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.querySelector('.custom-class')).toBeInTheDocument()
+ })
+
+ it('should handle isFullDocMode=true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - should have line-clamp-3 class
+ expect(container.querySelector('.line-clamp-3')).toBeInTheDocument()
+ })
+
+ it('should handle isFullDocMode=false with isCollapsed=true', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper(true) },
+ )
+
+ // Assert - should have line-clamp-2 class
+ expect(container.querySelector('.line-clamp-2')).toBeInTheDocument()
+ })
+
+ it('should handle isFullDocMode=false with isCollapsed=false', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper(false) },
+ )
+
+ // Assert - should have line-clamp-20 class
+ expect(container.querySelector('.line-clamp-20')).toBeInTheDocument()
+ })
+ })
+
+ // Content priority tests
+ describe('Content Priority', () => {
+ it('should prefer sign_content over content when both exist', () => {
+ // Arrange
+ const detail = {
+ content: 'Regular content',
+ sign_content: 'Sign content',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - The component uses sign_content || content
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should use content when sign_content is empty', () => {
+ // Arrange
+ const detail = {
+ content: 'Regular content',
+ sign_content: '',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty content', () => {
+ // Arrange
+ const emptyDetail = {
+ content: '',
+ sign_content: '',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle empty answer in QA mode', () => {
+ // Arrange
+ const qaDetail = {
+ content: 'Question',
+ sign_content: '',
+ answer: '',
+ }
+
+ // Act - empty answer is falsy, so QA mode won't render
+ render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Assert - should not show Q and A labels since answer is empty string (falsy)
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render(
+ ,
+ { wrapper: createWrapper() },
+ )
+
+ // Act
+ rerender(
+
+
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx
new file mode 100644
index 0000000000..491620d351
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx
@@ -0,0 +1,679 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ChunkingMode } from '@/models/datasets'
+
+import SegmentDetail from './segment-detail'
+
+// Mock dataset detail context
+let mockIndexingTechnique = IndexingType.QUALIFIED
+let mockRuntimeMode = 'general'
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: { dataset: { indexing_technique: string, runtime_mode: string } }) => unknown) => {
+ return selector({
+ dataset: {
+ indexing_technique: mockIndexingTechnique,
+ runtime_mode: mockRuntimeMode,
+ },
+ })
+ },
+}))
+
+// Mock document context
+let mockParentMode = 'paragraph'
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
+ return selector({ parentMode: mockParentMode })
+ },
+}))
+
+// Mock segment list context
+let mockFullScreen = false
+const mockToggleFullScreen = vi.fn()
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
+ const state = {
+ fullScreen: mockFullScreen,
+ toggleFullScreen: mockToggleFullScreen,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock event emitter context
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: {
+ useSubscription: vi.fn(),
+ },
+ }),
+}))
+
+// Mock child components
+vi.mock('./common/action-buttons', () => ({
+ default: ({ handleCancel, handleSave, handleRegeneration, loading, showRegenerationButton }: { handleCancel: () => void, handleSave: () => void, handleRegeneration?: () => void, loading: boolean, showRegenerationButton?: boolean }) => (
+
+
+
+ {showRegenerationButton && (
+
+ )}
+
+ ),
+}))
+
+vi.mock('./common/chunk-content', () => ({
+ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => (
+
+ onQuestionChange(e.target.value)}
+ />
+ {docForm === ChunkingMode.qa && (
+ onAnswerChange(e.target.value)}
+ />
+ )}
+ {isEditMode ? 'editing' : 'viewing'}
+
+ ),
+}))
+
+vi.mock('./common/dot', () => ({
+ default: () => •,
+}))
+
+vi.mock('./common/keywords', () => ({
+ default: ({ keywords, onKeywordsChange, _isEditMode, actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, actionType: string }) => (
+
+ {actionType}
+ onKeywordsChange(e.target.value.split(',').filter(Boolean))}
+ />
+
+ ),
+}))
+
+vi.mock('./common/segment-index-tag', () => ({
+ SegmentIndexTag: ({ positionId, label, labelPrefix }: { positionId?: string, label?: string, labelPrefix?: string }) => (
+
+ {labelPrefix}
+ {' '}
+ {positionId}
+ {' '}
+ {label}
+
+ ),
+}))
+
+vi.mock('./common/regeneration-modal', () => ({
+ default: ({ isShow, onConfirm, onCancel, onClose }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, onClose: () => void }) => (
+ isShow
+ ? (
+
+
+
+
+
+ )
+ : null
+ ),
+}))
+
+vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk', () => ({
+ default: ({ disabled, value, onChange }: { value?: unknown[], onChange?: (v: unknown[]) => void, disabled?: boolean }) => {
+ return (
+
+ {disabled ? 'disabled' : 'enabled'}
+ {value?.length || 0}
+
+
+ )
+ },
+}))
+
+describe('SegmentDetail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFullScreen = false
+ mockIndexingTechnique = IndexingType.QUALIFIED
+ mockRuntimeMode = 'general'
+ mockParentMode = 'paragraph'
+ })
+
+ const defaultSegInfo = {
+ id: 'segment-1',
+ content: 'Test content',
+ sign_content: 'Signed content',
+ answer: 'Test answer',
+ position: 1,
+ word_count: 100,
+ keywords: ['keyword1', 'keyword2'],
+ attachments: [],
+ }
+
+ const defaultProps = {
+ segInfo: defaultSegInfo,
+ onUpdate: vi.fn(),
+ onCancel: vi.fn(),
+ isEditMode: false,
+ docForm: ChunkingMode.text,
+ onModalStateChange: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render title for view mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.chunkDetail/i)).toBeInTheDocument()
+ })
+
+ it('should render title for edit mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/segment\.editChunk/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk content component', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
+ })
+
+ it('should render image uploader', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('image-uploader')).toBeInTheDocument()
+ })
+
+ it('should render segment index tag', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+ })
+
+ // Edit mode vs View mode
+ describe('Edit/View Mode', () => {
+ it('should pass isEditMode to ChunkContent', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
+ })
+
+ it('should disable image uploader in view mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('disabled')
+ })
+
+ it('should enable image uploader in edit mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('enabled')
+ })
+
+ it('should show action buttons in edit mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should not show action buttons in view mode (non-fullscreen)', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('action-buttons')).not.toBeInTheDocument()
+ })
+ })
+
+ // Keywords display
+ describe('Keywords', () => {
+ it('should show keywords component when indexing is ECONOMICAL', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords')).toBeInTheDocument()
+ })
+
+ it('should not show keywords when indexing is QUALIFIED', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.QUALIFIED
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('keywords')).not.toBeInTheDocument()
+ })
+
+ it('should pass view action type when not in edit mode', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords-action')).toHaveTextContent('view')
+ })
+
+ it('should pass edit action type when in edit mode', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords-action')).toHaveTextContent('edit')
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const { container } = render()
+
+ // Act
+ const closeButtons = container.querySelectorAll('.cursor-pointer')
+ if (closeButtons.length > 1)
+ fireEvent.click(closeButtons[1])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call toggleFullScreen when expand button is clicked', () => {
+ // Arrange
+ const { container } = render()
+
+ // Act
+ const expandButtons = container.querySelectorAll('.cursor-pointer')
+ if (expandButtons.length > 0)
+ fireEvent.click(expandButtons[0])
+
+ // Assert
+ expect(mockToggleFullScreen).toHaveBeenCalled()
+ })
+
+ it('should call onUpdate when save is clicked', () => {
+ // Arrange
+ const mockOnUpdate = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ expect(mockOnUpdate).toHaveBeenCalledWith(
+ 'segment-1',
+ expect.any(String),
+ expect.any(String),
+ expect.any(Array),
+ expect.any(Array),
+ )
+ })
+
+ it('should update question when input changes', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('question-input'), {
+ target: { value: 'Updated content' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('question-input')).toHaveValue('Updated content')
+ })
+ })
+
+ // Regeneration Modal
+ describe('Regeneration Modal', () => {
+ it('should show regeneration button when runtimeMode is general', () => {
+ // Arrange
+ mockRuntimeMode = 'general'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument()
+ })
+
+ it('should not show regeneration button when runtimeMode is not general', () => {
+ // Arrange
+ mockRuntimeMode = 'pipeline'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument()
+ })
+
+ it('should show regeneration modal when regenerate is clicked', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Assert
+ expect(screen.getByTestId('regeneration-modal')).toBeInTheDocument()
+ })
+
+ it('should call onModalStateChange when regeneration modal opens', () => {
+ // Arrange
+ const mockOnModalStateChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Assert
+ expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
+ })
+
+ it('should close modal when cancel is clicked', () => {
+ // Arrange
+ const mockOnModalStateChange = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('cancel-regeneration'))
+
+ // Assert
+ expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
+ expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ // Full screen mode
+ describe('Full Screen Mode', () => {
+ it('should show action buttons in header when fullScreen and editMode', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+
+ it('should apply full screen styling when fullScreen is true', () => {
+ // Arrange
+ mockFullScreen = true
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const header = container.querySelector('.border-divider-subtle')
+ expect(header).toBeInTheDocument()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle segInfo with minimal data', () => {
+ // Arrange
+ const minimalSegInfo = {
+ id: 'segment-minimal',
+ position: 1,
+ word_count: 0,
+ }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle empty keywords array', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+ const segInfo = { ...defaultSegInfo, keywords: [] }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('keywords-input')).toHaveValue('')
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
+ })
+ })
+
+ // Attachments
+ describe('Attachments', () => {
+ it('should update attachments when onChange is called', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('add-attachment-btn'))
+
+ // Assert
+ expect(screen.getByTestId('attachments-count')).toHaveTextContent('1')
+ })
+
+ it('should pass attachments to onUpdate when save is clicked', () => {
+ // Arrange
+ const mockOnUpdate = vi.fn()
+ render()
+
+ // Add an attachment
+ fireEvent.click(screen.getByTestId('add-attachment-btn'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('save-btn'))
+
+ // Assert
+ expect(mockOnUpdate).toHaveBeenCalledWith(
+ 'segment-1',
+ expect.any(String),
+ expect.any(String),
+ expect.any(Array),
+ expect.arrayContaining([expect.objectContaining({ id: 'new-attachment' })]),
+ )
+ })
+
+ it('should initialize attachments from segInfo', () => {
+ // Arrange
+ const segInfoWithAttachments = {
+ ...defaultSegInfo,
+ attachments: [
+ { id: 'att-1', name: 'file1.jpg', size: 1000, mime_type: 'image/jpeg', extension: 'jpg', source_url: 'http://example.com/file1.jpg' },
+ ],
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('attachments-count')).toHaveTextContent('1')
+ })
+ })
+
+ // Regeneration confirmation
+ describe('Regeneration Confirmation', () => {
+ it('should call onUpdate with needRegenerate true when confirm regeneration is clicked', () => {
+ // Arrange
+ const mockOnUpdate = vi.fn()
+ render()
+
+ // Open regeneration modal
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('confirm-regeneration'))
+
+ // Assert
+ expect(mockOnUpdate).toHaveBeenCalledWith(
+ 'segment-1',
+ expect.any(String),
+ expect.any(String),
+ expect.any(Array),
+ expect.any(Array),
+ true,
+ )
+ })
+
+ it('should close modal and edit drawer when close after regeneration is clicked', () => {
+ // Arrange
+ const mockOnCancel = vi.fn()
+ const mockOnModalStateChange = vi.fn()
+ render(
+ ,
+ )
+
+ // Open regeneration modal
+ fireEvent.click(screen.getByTestId('regenerate-btn'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('close-regeneration'))
+
+ // Assert
+ expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+ })
+
+ // QA mode
+ describe('QA Mode', () => {
+ it('should render answer input in QA mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('answer-input')).toBeInTheDocument()
+ })
+
+ it('should update answer when input changes', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('answer-input'), {
+ target: { value: 'Updated answer' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('answer-input')).toHaveValue('Updated answer')
+ })
+
+ it('should calculate word count correctly in QA mode', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - should show combined length of question and answer
+ expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument()
+ })
+ })
+
+ // Full doc mode
+ describe('Full Doc Mode', () => {
+ it('should show label in full-doc parent-child mode', () => {
+ // Arrange
+ mockParentMode = 'full-doc'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
+ })
+ })
+
+ // Keywords update
+ describe('Keywords Update', () => {
+ it('should update keywords when changed in edit mode', () => {
+ // Arrange
+ mockIndexingTechnique = IndexingType.ECONOMICAL
+ render()
+
+ // Act
+ fireEvent.change(screen.getByTestId('keywords-input'), {
+ target: { value: 'new,keywords' },
+ })
+
+ // Assert
+ expect(screen.getByTestId('keywords-input')).toHaveValue('new,keywords')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx
new file mode 100644
index 0000000000..1716059883
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx
@@ -0,0 +1,442 @@
+import type { SegmentDetailModel } from '@/models/datasets'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+
+import SegmentList from './segment-list'
+
+// Mock document context
+let mockDocForm = ChunkingMode.text
+let mockParentMode = 'paragraph'
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { docForm: ChunkingMode, parentMode: string }) => unknown) => {
+ return selector({
+ docForm: mockDocForm,
+ parentMode: mockParentMode,
+ })
+ },
+}))
+
+// Mock segment list context
+let mockCurrSegment: { segInfo: { id: string } } | null = null
+let mockCurrChildChunk: { childChunkInfo: { segment_id: string } } | null = null
+vi.mock('./index', () => ({
+ useSegmentListContext: (selector: (state: { currSegment: { segInfo: { id: string } } | null, currChildChunk: { childChunkInfo: { segment_id: string } } | null }) => unknown) => {
+ return selector({
+ currSegment: mockCurrSegment,
+ currChildChunk: mockCurrChildChunk,
+ })
+ },
+}))
+
+// Mock child components
+vi.mock('./common/empty', () => ({
+ default: ({ onClearFilter }: { onClearFilter: () => void }) => (
+
+
+
+ ),
+}))
+
+vi.mock('./segment-card', () => ({
+ default: ({
+ detail,
+ onClick,
+ onChangeSwitch,
+ onClickEdit,
+ onDelete,
+ onDeleteChildChunk,
+ handleAddNewChildChunk,
+ onClickSlice,
+ archived,
+ embeddingAvailable,
+ focused,
+ }: {
+ detail: SegmentDetailModel
+ onClick: () => void
+ onChangeSwitch: (enabled: boolean, segId?: string) => Promise
+ onClickEdit: () => void
+ onDelete: (segId: string) => Promise
+ onDeleteChildChunk: (segId: string, childChunkId: string) => Promise
+ handleAddNewChildChunk: (parentChunkId: string) => void
+ onClickSlice: (childChunk: unknown) => void
+ archived: boolean
+ embeddingAvailable: boolean
+ focused: { segmentIndex: boolean, segmentContent: boolean }
+ }) => (
+
+ {detail.content}
+ {archived ? 'true' : 'false'}
+ {embeddingAvailable ? 'true' : 'false'}
+ {focused.segmentIndex ? 'true' : 'false'}
+ {focused.segmentContent ? 'true' : 'false'}
+
+
+
+
+
+
+
+
+ ),
+}))
+
+vi.mock('./skeleton/general-list-skeleton', () => ({
+ default: () => Loading...
,
+}))
+
+vi.mock('./skeleton/paragraph-list-skeleton', () => ({
+ default: () => Loading Paragraph...
,
+}))
+
+describe('SegmentList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDocForm = ChunkingMode.text
+ mockParentMode = 'paragraph'
+ mockCurrSegment = null
+ mockCurrChildChunk = null
+ })
+
+ const createMockSegment = (id: string, content: string): SegmentDetailModel => ({
+ id,
+ content,
+ position: 1,
+ word_count: 10,
+ tokens: 5,
+ hit_count: 0,
+ enabled: true,
+ status: 'completed',
+ created_at: Date.now(),
+ updated_at: Date.now(),
+ keywords: [],
+ document_id: 'doc-1',
+ sign_content: content,
+ index_node_id: `index-${id}`,
+ index_node_hash: `hash-${id}`,
+ answer: '',
+ error: null,
+ disabled_at: null,
+ disabled_by: null,
+ } as unknown as SegmentDetailModel)
+
+ const defaultProps = {
+ ref: null,
+ isLoading: false,
+ items: [createMockSegment('seg-1', 'Segment 1 content')],
+ selectedSegmentIds: [],
+ onSelected: vi.fn(),
+ onClick: vi.fn(),
+ onChangeSwitch: vi.fn(),
+ onDelete: vi.fn(),
+ onDeleteChildChunk: vi.fn(),
+ handleAddNewChildChunk: vi.fn(),
+ onClickSlice: vi.fn(),
+ archived: false,
+ embeddingAvailable: true,
+ onClearFilter: vi.fn(),
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render segment cards for each item', () => {
+ // Arrange
+ const items = [
+ createMockSegment('seg-1', 'Content 1'),
+ createMockSegment('seg-2', 'Content 2'),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('segment-card')).toHaveLength(2)
+ })
+
+ it('should render empty component when items is empty', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('empty')).toBeInTheDocument()
+ })
+ })
+
+ // Loading state
+ describe('Loading State', () => {
+ it('should render general skeleton when loading and docForm is text', () => {
+ // Arrange
+ mockDocForm = ChunkingMode.text
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('general-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render paragraph skeleton when loading and docForm is parentChild with paragraph mode', () => {
+ // Arrange
+ mockDocForm = ChunkingMode.parentChild
+ mockParentMode = 'paragraph'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('paragraph-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render general skeleton when loading and docForm is parentChild with full-doc mode', () => {
+ // Arrange
+ mockDocForm = ChunkingMode.parentChild
+ mockParentMode = 'full-doc'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('general-skeleton')).toBeInTheDocument()
+ })
+ })
+
+ // Props passing
+ describe('Props Passing', () => {
+ it('should pass archived prop to SegmentCard', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('archived')).toHaveTextContent('true')
+ })
+
+ it('should pass embeddingAvailable prop to SegmentCard', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('embedding-available')).toHaveTextContent('false')
+ })
+ })
+
+ // Focused state
+ describe('Focused State', () => {
+ it('should set focused index when currSegment matches', () => {
+ // Arrange
+ mockCurrSegment = { segInfo: { id: 'seg-1' } }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('focused-index')).toHaveTextContent('true')
+ })
+
+ it('should set focused content when currSegment matches', () => {
+ // Arrange
+ mockCurrSegment = { segInfo: { id: 'seg-1' } }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('focused-content')).toHaveTextContent('true')
+ })
+
+ it('should set focused when currChildChunk parent matches', () => {
+ // Arrange
+ mockCurrChildChunk = { childChunkInfo: { segment_id: 'seg-1' } }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('focused-index')).toHaveTextContent('true')
+ })
+ })
+
+ // Clear filter
+ describe('Clear Filter', () => {
+ it('should call onClearFilter when clear filter button is clicked', async () => {
+ // Arrange
+ const mockOnClearFilter = vi.fn()
+ render()
+
+ // Act
+ screen.getByTestId('clear-filter-btn').click()
+
+ // Assert
+ expect(mockOnClearFilter).toHaveBeenCalled()
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle single item without divider', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('segment-card')).toBeInTheDocument()
+ })
+
+ it('should handle multiple items with dividers', () => {
+ // Arrange
+ const items = [
+ createMockSegment('seg-1', 'Content 1'),
+ createMockSegment('seg-2', 'Content 2'),
+ createMockSegment('seg-3', 'Content 3'),
+ ]
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getAllByTestId('segment-card')).toHaveLength(3)
+ })
+
+ it('should maintain structure when rerendered with different items', () => {
+ // Arrange
+ const { rerender } = render(
+ ,
+ )
+
+ // Act
+ rerender(
+ ,
+ )
+
+ // Assert
+ expect(screen.getAllByTestId('segment-card')).toHaveLength(2)
+ })
+ })
+
+ // Checkbox Selection
+ describe('Checkbox Selection', () => {
+ it('should render checkbox for each segment', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Checkbox component should exist
+ const checkboxes = container.querySelectorAll('[class*="checkbox"]')
+ expect(checkboxes.length).toBeGreaterThan(0)
+ })
+
+ it('should pass selectedSegmentIds to check state', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component should render with selected state
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle empty selectedSegmentIds', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component should render
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // Card Actions
+ describe('Card Actions', () => {
+ it('should call onClick when card is clicked', () => {
+ // Arrange
+ const mockOnClick = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('card-click'))
+
+ // Assert
+ expect(mockOnClick).toHaveBeenCalled()
+ })
+
+ it('should call onChangeSwitch when switch button is clicked', async () => {
+ // Arrange
+ const mockOnChangeSwitch = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('switch-btn'))
+
+ // Assert
+ expect(mockOnChangeSwitch).toHaveBeenCalledWith(true, 'seg-1')
+ })
+
+ it('should call onDelete when delete button is clicked', async () => {
+ // Arrange
+ const mockOnDelete = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('delete-btn'))
+
+ // Assert
+ expect(mockOnDelete).toHaveBeenCalledWith('seg-1')
+ })
+
+ it('should call onDeleteChildChunk when delete child button is clicked', async () => {
+ // Arrange
+ const mockOnDeleteChildChunk = vi.fn().mockResolvedValue(undefined)
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('delete-child-btn'))
+
+ // Assert
+ expect(mockOnDeleteChildChunk).toHaveBeenCalledWith('seg-1', 'child-1')
+ })
+
+ it('should call handleAddNewChildChunk when add child button is clicked', () => {
+ // Arrange
+ const mockHandleAddNewChildChunk = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('add-child-btn'))
+
+ // Assert
+ expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('seg-1')
+ })
+
+ it('should call onClickSlice when click slice button is clicked', () => {
+ // Arrange
+ const mockOnClickSlice = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('click-slice-btn'))
+
+ // Assert
+ expect(mockOnClickSlice).toHaveBeenCalledWith({ id: 'slice-1' })
+ })
+
+ it('should call onClick with edit mode when edit button is clicked', () => {
+ // Arrange
+ const mockOnClick = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByTestId('edit-btn'))
+
+ // Assert - onClick is called from onClickEdit with isEditMode=true
+ expect(mockOnClick).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx
index 2f7cf02e4e..08ba55cc35 100644
--- a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx
@@ -1,93 +1,124 @@
import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
import FullDocListSkeleton from './full-doc-list-skeleton'
describe('FullDocListSkeleton', () => {
+ // Rendering tests
describe('Rendering', () => {
- it('should render the skeleton container', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
const { container } = render()
- const skeletonContainer = container.firstChild
- expect(skeletonContainer).toHaveClass('flex', 'w-full', 'grow', 'flex-col')
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
})
- it('should render 15 Slice components', () => {
+ it('should render the correct number of slice elements', () => {
+ // Arrange & Act
const { container } = render()
- // Each Slice has a specific structure with gap-y-1
- const slices = container.querySelectorAll('.gap-y-1')
- expect(slices.length).toBe(15)
+ // Assert - component renders 15 slices
+ const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1')
+ expect(sliceElements).toHaveLength(15)
})
- it('should render mask overlay', () => {
+ it('should render mask overlay element', () => {
+ // Arrange & Act
const { container } = render()
- const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
- expect(maskOverlay).toBeInTheDocument()
+ // Assert - check for the mask overlay element
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
})
- it('should have overflow hidden', () => {
+ it('should render with correct container classes', () => {
+ // Arrange & Act
const { container } = render()
- const skeletonContainer = container.firstChild
- expect(skeletonContainer).toHaveClass('overflow-y-hidden')
+ // Assert
+ const containerElement = container.firstChild as HTMLElement
+ expect(containerElement).toHaveClass('relative')
+ expect(containerElement).toHaveClass('z-10')
+ expect(containerElement).toHaveClass('flex')
+ expect(containerElement).toHaveClass('w-full')
+ expect(containerElement).toHaveClass('grow')
+ expect(containerElement).toHaveClass('flex-col')
+ expect(containerElement).toHaveClass('gap-y-3')
+ expect(containerElement).toHaveClass('overflow-y-hidden')
})
})
- describe('Slice Component', () => {
- it('should render slice with correct structure', () => {
+ // Structure tests
+ describe('Structure', () => {
+ it('should render slice elements with proper structure', () => {
+ // Arrange & Act
const { container } = render()
- // Each slice has two rows
- const sliceRows = container.querySelectorAll('.bg-state-base-hover')
- expect(sliceRows.length).toBeGreaterThan(0)
+ // Assert - each slice should have the content placeholder elements
+ const slices = container.querySelectorAll('.flex.flex-col.gap-y-1')
+ slices.forEach((slice) => {
+ // Each slice should have children for the skeleton content
+ expect(slice.children.length).toBeGreaterThan(0)
+ })
})
- it('should render label placeholder in each slice', () => {
+ it('should render slice with width placeholder elements', () => {
+ // Arrange & Act
const { container } = render()
- // Label placeholder has specific width
- const labelPlaceholders = container.querySelectorAll('.w-\\[30px\\]')
- expect(labelPlaceholders.length).toBe(15) // One per slice
+ // Assert - check for skeleton content width class
+ const widthElements = container.querySelectorAll('.w-2\\/3')
+ expect(widthElements.length).toBeGreaterThan(0)
})
- it('should render content placeholder in each slice', () => {
+ it('should render slice elements with background classes', () => {
+ // Arrange & Act
const { container } = render()
- // Content placeholder has 2/3 width
- const contentPlaceholders = container.querySelectorAll('.w-2\\/3')
- expect(contentPlaceholders.length).toBe(15) // One per slice
+ // Assert - check for skeleton background classes
+ const bgElements = container.querySelectorAll('.bg-state-base-hover')
+ expect(bgElements.length).toBeGreaterThan(0)
})
})
+ // Memoization tests
describe('Memoization', () => {
- it('should be memoized', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert - structure should be identical
+ const slices1 = container1.querySelectorAll('.flex.flex-col.gap-y-1')
+ const slices2 = container2.querySelectorAll('.flex.flex-col.gap-y-1')
+ expect(slices1.length).toBe(slices2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rendered multiple times', () => {
+ // Arrange
const { rerender, container } = render()
- const initialContent = container.innerHTML
-
- // Rerender should produce same output
+ // Act
+ rerender()
rerender()
- expect(container.innerHTML).toBe(initialContent)
- })
- })
-
- describe('Styling', () => {
- it('should have correct z-index layering', () => {
- const { container } = render()
-
- const skeletonContainer = container.firstChild
- expect(skeletonContainer).toHaveClass('z-10')
-
- const maskOverlay = container.querySelector('.z-20')
- expect(maskOverlay).toBeInTheDocument()
+ // Assert
+ const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1')
+ expect(sliceElements).toHaveLength(15)
})
- it('should have gap between slices', () => {
+ it('should not have accessibility issues with skeleton content', () => {
+ // Arrange & Act
const { container } = render()
- const skeletonContainer = container.firstChild
- expect(skeletonContainer).toHaveClass('gap-y-3')
+ // Assert - skeleton should be purely visual, no interactive elements
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(0)
+ expect(links).toHaveLength(0)
})
})
})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx
new file mode 100644
index 0000000000..0430724671
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx
@@ -0,0 +1,195 @@
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import GeneralListSkeleton, { CardSkelton } from './general-list-skeleton'
+
+describe('CardSkelton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render skeleton rows', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component should have skeleton rectangle elements
+ const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletonRectangles.length).toBeGreaterThan(0)
+ })
+
+ it('should render with proper container padding', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.querySelector('.p-1')).toBeInTheDocument()
+ expect(container.querySelector('.pb-2')).toBeInTheDocument()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render skeleton points as separators', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for opacity class on skeleton points
+ const opacityElements = container.querySelectorAll('.opacity-20')
+ expect(opacityElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render width-constrained skeleton elements', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for various width classes
+ expect(container.querySelector('.w-\\[72px\\]')).toBeInTheDocument()
+ expect(container.querySelector('.w-24')).toBeInTheDocument()
+ expect(container.querySelector('.w-full')).toBeInTheDocument()
+ })
+ })
+})
+
+describe('GeneralListSkeleton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the correct number of list items', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component renders 10 items (Checkbox is a div with shrink-0 and h-4 w-4)
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should render mask overlay element', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const containerElement = container.firstChild as HTMLElement
+ expect(containerElement).toHaveClass('relative')
+ expect(containerElement).toHaveClass('z-10')
+ expect(containerElement).toHaveClass('flex')
+ expect(containerElement).toHaveClass('grow')
+ expect(containerElement).toHaveClass('flex-col')
+ expect(containerElement).toHaveClass('overflow-y-hidden')
+ })
+ })
+
+ // Checkbox tests
+ describe('Checkboxes', () => {
+ it('should render disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Checkbox component uses cursor-not-allowed class when disabled
+ const disabledCheckboxes = container.querySelectorAll('.cursor-not-allowed')
+ expect(disabledCheckboxes.length).toBeGreaterThan(0)
+ })
+
+ it('should render checkboxes with shrink-0 class for consistent sizing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const checkboxContainers = container.querySelectorAll('.shrink-0')
+ expect(checkboxContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Divider tests
+ describe('Dividers', () => {
+ it('should render dividers between items except for the last one', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - should have 9 dividers (not after last item)
+ const dividers = container.querySelectorAll('.bg-divider-subtle')
+ expect(dividers).toHaveLength(9)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render list items with proper gap styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const listItems = container.querySelectorAll('.gap-x-2')
+ expect(listItems.length).toBeGreaterThan(0)
+ })
+
+ it('should render CardSkelton inside each list item', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - each list item should contain card skeleton content
+ const cardContainers = container.querySelectorAll('.grow')
+ expect(cardContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ const checkboxes1 = container1.querySelectorAll('input[type="checkbox"]')
+ const checkboxes2 = container2.querySelectorAll('input[type="checkbox"]')
+ expect(checkboxes1.length).toBe(checkboxes2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should not have interactive elements besides disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(0)
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx
new file mode 100644
index 0000000000..a26b357e1e
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx
@@ -0,0 +1,151 @@
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import ParagraphListSkeleton from './paragraph-list-skeleton'
+
+describe('ParagraphListSkeleton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the correct number of list items', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - component renders 10 items
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should render mask overlay element', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(maskElement).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const containerElement = container.firstChild as HTMLElement
+ expect(containerElement).toHaveClass('relative')
+ expect(containerElement).toHaveClass('z-10')
+ expect(containerElement).toHaveClass('flex')
+ expect(containerElement).toHaveClass('h-full')
+ expect(containerElement).toHaveClass('flex-col')
+ expect(containerElement).toHaveClass('overflow-y-hidden')
+ })
+ })
+
+ // Checkbox tests
+ describe('Checkboxes', () => {
+ it('should render disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - Checkbox component uses cursor-not-allowed class when disabled
+ const disabledCheckboxes = container.querySelectorAll('.cursor-not-allowed')
+ expect(disabledCheckboxes.length).toBeGreaterThan(0)
+ })
+
+ it('should render checkboxes with shrink-0 class for consistent sizing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const checkboxContainers = container.querySelectorAll('.shrink-0')
+ expect(checkboxContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Divider tests
+ describe('Dividers', () => {
+ it('should render dividers between items except for the last one', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - should have 9 dividers (not after last item)
+ const dividers = container.querySelectorAll('.bg-divider-subtle')
+ expect(dividers).toHaveLength(9)
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render arrow icon for expand button styling', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - paragraph list skeleton has expand button styled area
+ const expandBtnElements = container.querySelectorAll('.bg-dataset-child-chunk-expand-btn-bg')
+ expect(expandBtnElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render skeleton rectangles with quaternary text color', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const skeletonElements = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletonElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render CardSkelton inside each list item', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - each list item should contain card skeleton content
+ const cardContainers = container.querySelectorAll('.grow')
+ expect(cardContainers.length).toBeGreaterThan(0)
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ const items1 = container1.querySelectorAll('.items-start.gap-x-2')
+ const items2 = container2.querySelectorAll('.items-start.gap-x-2')
+ expect(items1.length).toBe(items2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ const listItems = container.querySelectorAll('.items-start.gap-x-2')
+ expect(listItems).toHaveLength(10)
+ })
+
+ it('should not have interactive elements besides disabled checkboxes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(0)
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx
new file mode 100644
index 0000000000..71d15a9178
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx
@@ -0,0 +1,132 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import ParentChunkCardSkelton from './parent-chunk-card-skeleton'
+
+describe('ParentChunkCardSkelton', () => {
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const container = screen.getByTestId('parent-chunk-card-skeleton')
+ expect(container).toHaveClass('flex')
+ expect(container).toHaveClass('flex-col')
+ expect(container).toHaveClass('pb-2')
+ })
+
+ it('should render skeleton rectangles', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletonRectangles.length).toBeGreaterThan(0)
+ })
+ })
+
+ // i18n tests
+ describe('i18n', () => {
+ it('should render view more button with translated text', () => {
+ // Arrange & Act
+ render()
+
+ // Assert - the button should contain translated text
+ const viewMoreButton = screen.getByRole('button')
+ expect(viewMoreButton).toBeInTheDocument()
+ })
+
+ it('should render disabled view more button', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const viewMoreButton = screen.getByRole('button')
+ expect(viewMoreButton).toBeDisabled()
+ })
+ })
+
+ // Structure tests
+ describe('Structure', () => {
+ it('should render skeleton points as separators', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const opacityElements = container.querySelectorAll('.opacity-20')
+ expect(opacityElements.length).toBeGreaterThan(0)
+ })
+
+ it('should render width-constrained skeleton elements', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - check for various width classes
+ expect(container.querySelector('.w-\\[72px\\]')).toBeInTheDocument()
+ expect(container.querySelector('.w-24')).toBeInTheDocument()
+ expect(container.querySelector('.w-full')).toBeInTheDocument()
+ expect(container.querySelector('.w-2\\/3')).toBeInTheDocument()
+ })
+
+ it('should render button with proper styling classes', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('system-xs-semibold-uppercase')
+ expect(button).toHaveClass('text-components-button-secondary-accent-text-disabled')
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently across multiple renders', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ const skeletons1 = container1.querySelectorAll('.bg-text-quaternary')
+ const skeletons2 = container2.querySelectorAll('.bg-text-quaternary')
+ expect(skeletons1.length).toBe(skeletons2.length)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, container } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument()
+ const skeletons = container.querySelectorAll('.bg-text-quaternary')
+ expect(skeletons.length).toBeGreaterThan(0)
+ })
+
+ it('should have only one interactive element (disabled button)', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const buttons = container.querySelectorAll('button')
+ const links = container.querySelectorAll('a')
+ expect(buttons).toHaveLength(1)
+ expect(buttons[0]).toBeDisabled()
+ expect(links).toHaveLength(0)
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx b/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx
new file mode 100644
index 0000000000..a9114ffe79
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx
@@ -0,0 +1,118 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import StatusItem from './status-item'
+
+describe('StatusItem', () => {
+ const defaultItem = {
+ value: '1',
+ name: 'Test Status',
+ }
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render item name', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Test Status')).toBeInTheDocument()
+ })
+
+ it('should render with correct styling classes', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('justify-between')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should show check icon when selected is true', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - RiCheckLine icon should be present
+ const checkIcon = container.querySelector('.text-text-accent')
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ it('should not show check icon when selected is false', () => {
+ // Arrange & Act
+ const { container } = render()
+
+ // Assert - RiCheckLine icon should not be present
+ const checkIcon = container.querySelector('.text-text-accent')
+ expect(checkIcon).not.toBeInTheDocument()
+ })
+
+ it('should render different item names', () => {
+ // Arrange & Act
+ const item = { value: '2', name: 'Different Status' }
+ render()
+
+ // Assert
+ expect(screen.getByText('Different Status')).toBeInTheDocument()
+ })
+ })
+
+ // Memoization tests
+ describe('Memoization', () => {
+ it('should render consistently with same props', () => {
+ // Arrange & Act
+ const { container: container1 } = render()
+ const { container: container2 } = render()
+
+ // Assert
+ expect(container1.textContent).toBe(container2.textContent)
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle empty item name', () => {
+ // Arrange
+ const emptyItem = { value: '1', name: '' }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle special characters in item name', () => {
+ // Arrange
+ const specialItem = { value: '1', name: 'Status <>&"' }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Status <>&"')).toBeInTheDocument()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender } = render()
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(screen.getByText('Test Status')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/document-title.spec.tsx b/web/app/components/datasets/documents/detail/document-title.spec.tsx
new file mode 100644
index 0000000000..dca2d068ec
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/document-title.spec.tsx
@@ -0,0 +1,169 @@
+import { render } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ChunkingMode } from '@/models/datasets'
+
+import { DocumentTitle } from './document-title'
+
+// Mock next/navigation
+const mockPush = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+// Mock DocumentPicker
+vi.mock('../../common/document-picker', () => ({
+ default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => (
+ onChange({ id: 'new-doc-id' })}
+ >
+ Document Picker
+
+ ),
+}))
+
+describe('DocumentTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render DocumentPicker component', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ expect(getByTestId('document-picker')).toBeInTheDocument()
+ })
+
+ it('should render with correct container classes', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('flex-1')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('justify-start')
+ })
+ })
+
+ // Props tests
+ describe('Props', () => {
+ it('should pass datasetId to DocumentPicker', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id')
+ })
+
+ it('should pass value props to DocumentPicker', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
+ expect(value.name).toBe('test-document')
+ expect(value.extension).toBe('pdf')
+ expect(value.chunkingMode).toBe(ChunkingMode.text)
+ expect(value.parentMode).toBe('paragraph')
+ })
+
+ it('should default parentMode to paragraph when parent_mode is undefined', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
+ expect(value.parentMode).toBe('paragraph')
+ })
+
+ it('should apply custom wrapperCls', () => {
+ // Arrange & Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-wrapper')
+ })
+ })
+
+ // Navigation tests
+ describe('Navigation', () => {
+ it('should navigate to document page when document is selected', () => {
+ // Arrange
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Act
+ getByTestId('document-picker').click()
+
+ // Assert
+ expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/new-doc-id')
+ })
+ })
+
+ // Edge cases
+ describe('Edge Cases', () => {
+ it('should handle undefined optional props', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+ ,
+ )
+
+ // Assert
+ const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}')
+ expect(value.name).toBeUndefined()
+ expect(value.extension).toBeUndefined()
+ })
+
+ it('should maintain structure when rerendered', () => {
+ // Arrange
+ const { rerender, getByTestId } = render(
+ ,
+ )
+
+ // Act
+ rerender()
+
+ // Assert
+ expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx
index ea2c453355..e147bf9aba 100644
--- a/web/app/components/datasets/documents/detail/index.tsx
+++ b/web/app/components/datasets/documents/detail/index.tsx
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
-import type { DataSourceInfo, FileItem, LegacyDataSourceInfo } from '@/models/datasets'
+import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
@@ -256,7 +256,7 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => {
className="mr-2 mt-3"
datasetId={datasetId}
documentId={documentId}
- docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as any}
+ docDetail={{ ...documentDetail, ...documentMetadata, doc_type: documentMetadata?.doc_type === 'others' ? '' : documentMetadata?.doc_type } as FullDocumentDetail}
/>
diff --git a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx b/web/app/components/datasets/documents/detail/metadata/index.spec.tsx
new file mode 100644
index 0000000000..367449f1b9
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/metadata/index.spec.tsx
@@ -0,0 +1,545 @@
+import type { FullDocumentDetail } from '@/models/datasets'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import Metadata, { FieldInfo } from './index'
+
+// Mock document context
+vi.mock('../context', () => ({
+ useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => {
+ return selector({ datasetId: 'test-dataset-id', documentId: 'test-document-id' })
+ },
+}))
+
+// Mock ToastContext
+const mockNotify = vi.fn()
+vi.mock('use-context-selector', async (importOriginal) => {
+ const actual = await importOriginal() as Record