diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/components.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/components.spec.tsx
new file mode 100644
index 0000000000..560a615d49
--- /dev/null
+++ b/web/app/components/datasets/create-from-pipeline/list/template-card/components.spec.tsx
@@ -0,0 +1,1039 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Content from './content'
+import Actions from './actions'
+import Operations from './operations'
+import EditPipelineInfo from './edit-pipeline-info'
+import type { PipelineTemplate } from '@/models/pipeline'
+import { ChunkingMode } from '@/models/datasets'
+import type { IconInfo } from '@/models/datasets'
+
+// Mock service hooks for EditPipelineInfo
+let mockUpdatePipeline: jest.Mock
+let mockInvalidCustomizedTemplateList: jest.Mock
+
+jest.mock('@/service/use-pipeline', () => ({
+ useUpdateTemplateInfo: () => ({
+ mutateAsync: mockUpdatePipeline,
+ }),
+ useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
+}))
+
+// Mock AppIconPicker (not a base component)
+jest.mock('@/app/components/base/app-icon-picker', () => ({
+ __esModule: true,
+ default: ({ onSelect, onClose }: {
+ onSelect: (icon: { type: string; icon?: string; background?: string; url?: string; fileId?: string }) => void
+ onClose: () => void
+ }) => (
+
+
+
+
+
+ ),
+}))
+
+// Factory functions
+const createMockIconInfo = (overrides: Partial = {}): IconInfo => ({
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ ...overrides,
+})
+
+const createMockPipeline = (overrides: Partial = {}): PipelineTemplate => ({
+ id: 'test-pipeline-id',
+ name: 'Test Pipeline',
+ description: 'Test pipeline description',
+ icon: createMockIconInfo(),
+ position: 1,
+ chunk_structure: ChunkingMode.text,
+ ...overrides,
+})
+
+describe('Template Card Components', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUpdatePipeline = jest.fn()
+ mockInvalidCustomizedTemplateList = jest.fn()
+ })
+
+ /**
+ * Content Component Tests
+ * Tests for the Content component that displays pipeline info
+ */
+ describe('Content', () => {
+ const createContentProps = () => ({
+ name: 'Test Pipeline',
+ description: 'Test description',
+ iconInfo: createMockIconInfo(),
+ chunkStructure: ChunkingMode.text,
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const props = createContentProps()
+
+ render()
+
+ expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
+ })
+
+ it('should render pipeline name', () => {
+ const props = { ...createContentProps(), name: 'My Custom Pipeline' }
+
+ render()
+
+ expect(screen.getByText('My Custom Pipeline')).toBeInTheDocument()
+ })
+
+ it('should render pipeline description', () => {
+ const props = { ...createContentProps(), description: 'This is a custom description' }
+
+ render()
+
+ expect(screen.getByText('This is a custom description')).toBeInTheDocument()
+ })
+
+ it('should render name with title attribute for truncation', () => {
+ const props = { ...createContentProps(), name: 'Long Pipeline Name' }
+
+ render()
+
+ const nameElement = screen.getByText('Long Pipeline Name')
+ expect(nameElement).toHaveAttribute('title', 'Long Pipeline Name')
+ expect(nameElement).toHaveClass('truncate')
+ })
+
+ it('should render description with title attribute', () => {
+ const props = { ...createContentProps(), description: 'Long description text' }
+
+ render()
+
+ const descElement = screen.getByText('Long description text')
+ expect(descElement).toHaveAttribute('title', 'Long description text')
+ })
+
+ it('should render chunking mode translation key', () => {
+ const props = { ...createContentProps(), chunkStructure: ChunkingMode.text }
+
+ render()
+
+ // Translation key format: dataset.chunkingMode.general
+ expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument()
+ })
+
+ it('should render qa chunking mode', () => {
+ const props = { ...createContentProps(), chunkStructure: ChunkingMode.qa }
+
+ render()
+
+ expect(screen.getByText('dataset.chunkingMode.qa')).toBeInTheDocument()
+ })
+
+ it('should render parentChild chunking mode', () => {
+ const props = { ...createContentProps(), chunkStructure: ChunkingMode.parentChild }
+
+ render()
+
+ expect(screen.getByText('dataset.chunkingMode.parentChild')).toBeInTheDocument()
+ })
+ })
+
+ describe('Icon Rendering', () => {
+ it('should handle emoji icon type', () => {
+ const props = {
+ ...createContentProps(),
+ iconInfo: createMockIconInfo({ icon_type: 'emoji', icon: '๐' }),
+ }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle image icon type', () => {
+ const props = {
+ ...createContentProps(),
+ iconInfo: createMockIconInfo({
+ icon_type: 'image',
+ icon: 'file-id',
+ icon_url: 'https://example.com/image.png',
+ }),
+ }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should use General icon as fallback for unknown chunk structure', () => {
+ const props = {
+ ...createContentProps(),
+ chunkStructure: 'unknown' as ChunkingMode,
+ }
+
+ // Should not throw and fallback to General icon
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty name', () => {
+ const props = { ...createContentProps(), name: '' }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle empty description', () => {
+ const props = { ...createContentProps(), description: '' }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle very long name', () => {
+ const longName = 'A'.repeat(200)
+ const props = { ...createContentProps(), name: longName }
+
+ render()
+
+ expect(screen.getByText(longName)).toBeInTheDocument()
+ })
+
+ it('should handle unicode characters', () => {
+ const props = {
+ ...createContentProps(),
+ name: 'ๆต่ฏ็ฎก้ ๐',
+ description: 'ๆฅๆฌ่ชใในใ',
+ }
+
+ render()
+
+ expect(screen.getByText('ๆต่ฏ็ฎก้ ๐')).toBeInTheDocument()
+ expect(screen.getByText('ๆฅๆฌ่ชใในใ')).toBeInTheDocument()
+ })
+ })
+
+ describe('Styling', () => {
+ it('should have correct typography classes for name', () => {
+ const props = createContentProps()
+
+ render()
+
+ const nameElement = screen.getByText('Test Pipeline')
+ expect(nameElement).toHaveClass('system-md-semibold')
+ expect(nameElement).toHaveClass('text-text-secondary')
+ })
+
+ it('should have correct typography classes for description', () => {
+ const props = createContentProps()
+
+ render()
+
+ const descElement = screen.getByText('Test description')
+ expect(descElement).toHaveClass('system-xs-regular')
+ expect(descElement).toHaveClass('text-text-tertiary')
+ expect(descElement).toHaveClass('line-clamp-3')
+ })
+ })
+ })
+
+ /**
+ * Actions Component Tests
+ * Tests for the Actions component with apply, details, and more operations buttons
+ */
+ describe('Actions', () => {
+ const createActionsProps = () => ({
+ onApplyTemplate: jest.fn(),
+ handleShowTemplateDetails: jest.fn(),
+ showMoreOperations: true,
+ openEditModal: jest.fn(),
+ handleExportDSL: jest.fn(),
+ handleDelete: jest.fn(),
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const props = createActionsProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.operations.choose')).toBeInTheDocument()
+ expect(screen.getByText('datasetPipeline.operations.details')).toBeInTheDocument()
+ })
+
+ it('should render apply template button', () => {
+ const props = createActionsProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.operations.choose')).toBeInTheDocument()
+ })
+
+ it('should render details button', () => {
+ const props = createActionsProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.operations.details')).toBeInTheDocument()
+ })
+
+ it('should render more operations popover button when showMoreOperations is true', () => {
+ const props = createActionsProps()
+
+ render()
+
+ // The popover button contains the RiMoreFill icon
+ const popoverButtons = screen.getAllByRole('button')
+ // Should have 3 buttons: choose, details, and more
+ expect(popoverButtons).toHaveLength(3)
+ })
+
+ it('should not render more operations popover button when showMoreOperations is false', () => {
+ const props = { ...createActionsProps(), showMoreOperations: false }
+
+ render()
+
+ // Should only have 2 buttons: choose and details
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(2)
+ })
+ })
+
+ describe('Event Handlers', () => {
+ it('should call onApplyTemplate when apply button is clicked', () => {
+ const props = createActionsProps()
+
+ render()
+
+ const applyButton = screen.getByText('datasetPipeline.operations.choose').closest('button')
+ fireEvent.click(applyButton!)
+
+ expect(props.onApplyTemplate).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call handleShowTemplateDetails when details button is clicked', () => {
+ const props = createActionsProps()
+
+ render()
+
+ const detailsButton = screen.getByText('datasetPipeline.operations.details').closest('button')
+ fireEvent.click(detailsButton!)
+
+ expect(props.handleShowTemplateDetails).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should handle showMoreOperations toggle', () => {
+ const props = createActionsProps()
+
+ const { rerender } = render()
+ // Should have 3 buttons when showMoreOperations is true
+ expect(screen.getAllByRole('button')).toHaveLength(3)
+
+ rerender()
+ // Should have 2 buttons when showMoreOperations is false
+ expect(screen.getAllByRole('button')).toHaveLength(2)
+ })
+ })
+ })
+
+ /**
+ * Operations Component Tests
+ * Tests for the Operations dropdown menu
+ */
+ describe('Operations', () => {
+ const createOperationsProps = () => ({
+ openEditModal: jest.fn(),
+ onDelete: jest.fn(),
+ onExport: jest.fn(),
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.operations.editInfo')).toBeInTheDocument()
+ expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
+ })
+
+ it('should render edit option', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.operations.editInfo')).toBeInTheDocument()
+ })
+
+ it('should render export option', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
+ })
+
+ it('should render delete option', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
+ })
+ })
+
+ describe('Event Handlers', () => {
+ it('should call openEditModal when edit is clicked', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ const editOption = screen.getByText('datasetPipeline.operations.editInfo').closest('div')
+ fireEvent.click(editOption!)
+
+ expect(props.openEditModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onExport when export is clicked', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ const exportOption = screen.getByText('datasetPipeline.operations.exportPipeline').closest('div')
+ fireEvent.click(exportOption!)
+
+ expect(props.onExport).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onDelete when delete is clicked', () => {
+ const props = createOperationsProps()
+
+ render()
+
+ const deleteOption = screen.getByText('common.operation.delete').closest('div')
+ fireEvent.click(deleteOption!)
+
+ expect(props.onDelete).toHaveBeenCalledTimes(1)
+ })
+
+ it('should stop propagation on edit click', () => {
+ const props = createOperationsProps()
+ const parentClickHandler = jest.fn()
+
+ render(
+
+
+
,
+ )
+
+ const editOption = screen.getByText('datasetPipeline.operations.editInfo').closest('div')
+ fireEvent.click(editOption!)
+
+ expect(parentClickHandler).not.toHaveBeenCalled()
+ })
+
+ it('should stop propagation on export click', () => {
+ const props = createOperationsProps()
+ const parentClickHandler = jest.fn()
+
+ render(
+
+
+
,
+ )
+
+ const exportOption = screen.getByText('datasetPipeline.operations.exportPipeline').closest('div')
+ fireEvent.click(exportOption!)
+
+ expect(parentClickHandler).not.toHaveBeenCalled()
+ })
+
+ it('should stop propagation on delete click', () => {
+ const props = createOperationsProps()
+ const parentClickHandler = jest.fn()
+
+ render(
+
+
+
,
+ )
+
+ const deleteOption = screen.getByText('common.operation.delete').closest('div')
+ fireEvent.click(deleteOption!)
+
+ expect(parentClickHandler).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Styling', () => {
+ it('should have correct container styling', () => {
+ const props = createOperationsProps()
+
+ const { container } = render()
+
+ const operationsContainer = container.firstChild as HTMLElement
+ expect(operationsContainer).toHaveClass('rounded-xl')
+ expect(operationsContainer).toHaveClass('border-[0.5px]')
+ })
+ })
+ })
+
+ /**
+ * EditPipelineInfo Component Tests
+ * Tests for the edit pipeline info modal
+ */
+ describe('EditPipelineInfo', () => {
+ const createEditPipelineInfoProps = () => ({
+ onClose: jest.fn(),
+ pipeline: createMockPipeline(),
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.editPipelineInfo')).toBeInTheDocument()
+ })
+
+ it('should render header title', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.editPipelineInfo')).toBeInTheDocument()
+ })
+
+ it('should render close button', () => {
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ const closeButton = container.querySelector('button.absolute.right-5')
+ expect(closeButton).toBeInTheDocument()
+ })
+
+ it('should render name input with initial value', () => {
+ const pipeline = createMockPipeline({ name: 'Initial Name' })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ const input = screen.getByPlaceholderText('datasetPipeline.knowledgeNameAndIconPlaceholder')
+ expect(input).toHaveValue('Initial Name')
+ })
+
+ it('should render description textarea with initial value', () => {
+ const pipeline = createMockPipeline({ description: 'Initial Description' })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ const textarea = screen.getByPlaceholderText('datasetPipeline.knowledgeDescriptionPlaceholder')
+ expect(textarea).toHaveValue('Initial Description')
+ })
+
+ it('should render cancel and save buttons', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.save')).toBeInTheDocument()
+ })
+
+ it('should render labels', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ expect(screen.getByText('datasetPipeline.pipelineNameAndIcon')).toBeInTheDocument()
+ expect(screen.getByText('datasetPipeline.knowledgeDescription')).toBeInTheDocument()
+ })
+ })
+
+ describe('State Management', () => {
+ it('should update name when input changes', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const input = screen.getByPlaceholderText('datasetPipeline.knowledgeNameAndIconPlaceholder')
+ fireEvent.change(input, { target: { value: 'New Name' } })
+
+ expect(input).toHaveValue('New Name')
+ })
+
+ it('should update description when textarea changes', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const textarea = screen.getByPlaceholderText('datasetPipeline.knowledgeDescriptionPlaceholder')
+ fireEvent.change(textarea, { target: { value: 'New Description' } })
+
+ expect(textarea).toHaveValue('New Description')
+ })
+
+ it('should not show icon picker initially', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Event Handlers', () => {
+ it('should call onClose when close button is clicked', () => {
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ const closeButton = container.querySelector('button.absolute.right-5')
+ fireEvent.click(closeButton!)
+
+ expect(props.onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClose when cancel button is clicked', () => {
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(props.onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call updatePipeline when save button is clicked', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ template_id: 'test-pipeline-id',
+ name: 'Test Pipeline',
+ description: 'Test pipeline description',
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should not call updatePipeline when name is empty', async () => {
+ const pipeline = createMockPipeline({ name: '' })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePipeline).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should invalidate customized template list on success', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onClose on successful save', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(props.onClose).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Icon Picker', () => {
+ it('should show icon picker when app icon is clicked', () => {
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ // Find the AppIcon with onClick handler (has cursor-pointer class)
+ const appIcon = container.querySelector('.cursor-pointer')
+ fireEvent.click(appIcon!)
+
+ expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
+ })
+
+ it('should close icon picker when close is clicked', () => {
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ const appIcon = container.querySelector('.cursor-pointer')
+ fireEvent.click(appIcon!)
+
+ expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('close-icon-picker'))
+
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+ })
+
+ it('should update icon when new icon is selected', () => {
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ const appIcon = container.querySelector('.cursor-pointer')
+ fireEvent.click(appIcon!)
+
+ fireEvent.click(screen.getByTestId('select-emoji-icon'))
+
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+ })
+
+ it('should restore previous icon when picker is closed without selection', () => {
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ const appIcon = container.querySelector('.cursor-pointer')
+ fireEvent.click(appIcon!)
+
+ fireEvent.click(screen.getByTestId('close-icon-picker'))
+
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Icon Types', () => {
+ it('should handle emoji icon type from pipeline', () => {
+ const pipeline = createMockPipeline({
+ icon: createMockIconInfo({
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#E6F4FF',
+ }),
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle image icon type from pipeline', () => {
+ const pipeline = createMockPipeline({
+ icon: createMockIconInfo({
+ icon_type: 'image',
+ icon: 'file-id',
+ icon_url: 'https://example.com/image.png',
+ }),
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle image icon type with empty url and fileId (fallback)', () => {
+ // This covers the || '' fallback branches in lines 28-29 and 35-36
+ const pipeline = createMockPipeline({
+ icon: {
+ icon_type: 'image',
+ icon: '',
+ icon_url: '',
+ icon_background: '',
+ },
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle image icon type with undefined url and fileId', () => {
+ // This covers the || '' fallback branches when values are undefined
+ const pipeline = createMockPipeline({
+ icon: {
+ icon_type: 'image',
+ icon: undefined as unknown as string,
+ icon_url: undefined as unknown as string,
+ icon_background: '',
+ },
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should handle emoji icon type with empty values (fallback)', () => {
+ // This covers the || '' fallback branches for emoji type
+ const pipeline = createMockPipeline({
+ icon: {
+ icon_type: 'emoji',
+ icon: '',
+ icon_url: '',
+ icon_background: '',
+ },
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ describe('Form Submission', () => {
+ it('should submit with emoji icon type', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ icon_info: expect.objectContaining({
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFF4ED',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should submit with image icon type', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const pipeline = createMockPipeline({
+ icon: createMockIconInfo({
+ icon_type: 'image',
+ icon: 'file-id-123',
+ icon_url: 'https://example.com/image.png',
+ }),
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ icon_info: expect.objectContaining({
+ icon_type: 'image',
+ icon: 'file-id-123',
+ icon_url: 'https://example.com/image.png',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should submit with updated name and description', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const props = createEditPipelineInfoProps()
+
+ render()
+
+ const nameInput = screen.getByPlaceholderText('datasetPipeline.knowledgeNameAndIconPlaceholder')
+ const descTextarea = screen.getByPlaceholderText('datasetPipeline.knowledgeDescriptionPlaceholder')
+
+ fireEvent.change(nameInput, { target: { value: 'Updated Name' } })
+ fireEvent.change(descTextarea, { target: { value: 'Updated Description' } })
+
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Updated Name',
+ description: 'Updated Description',
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should submit with selected image icon from picker', async () => {
+ mockUpdatePipeline = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess()
+ })
+ const props = createEditPipelineInfoProps()
+
+ const { container } = render()
+
+ // Open icon picker and select image icon
+ const appIcon = container.querySelector('.cursor-pointer')
+ fireEvent.click(appIcon!)
+ fireEvent.click(screen.getByTestId('select-image-icon'))
+
+ // Save with the new image icon
+ const saveButton = screen.getByText('common.operation.save')
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(mockUpdatePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ icon_info: expect.objectContaining({
+ icon_type: 'image',
+ icon: 'new-file-id',
+ icon_url: 'https://example.com/new.png',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty initial description', () => {
+ const pipeline = createMockPipeline({ description: '' })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ const textarea = screen.getByPlaceholderText('datasetPipeline.knowledgeDescriptionPlaceholder')
+ expect(textarea).toHaveValue('')
+ })
+
+ it('should handle very long name', () => {
+ const longName = 'A'.repeat(200)
+ const pipeline = createMockPipeline({ name: longName })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ const input = screen.getByPlaceholderText('datasetPipeline.knowledgeNameAndIconPlaceholder')
+ expect(input).toHaveValue(longName)
+ })
+
+ it('should handle unicode characters', () => {
+ const pipeline = createMockPipeline({
+ name: 'ๆต่ฏ็ฎก้ ๐',
+ description: 'ๆฅๆฌ่ชใในใ',
+ })
+ const props = { ...createEditPipelineInfoProps(), pipeline }
+
+ render()
+
+ expect(screen.getByDisplayValue('ๆต่ฏ็ฎก้ ๐')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('ๆฅๆฌ่ชใในใ')).toBeInTheDocument()
+ })
+ })
+ })
+
+ /**
+ * Component Memoization Tests
+ * Tests for React.memo behavior across all components
+ */
+ describe('Component Memoization', () => {
+ it('Content should render correctly after rerender', () => {
+ const props = {
+ name: 'Test',
+ description: 'Desc',
+ iconInfo: createMockIconInfo(),
+ chunkStructure: ChunkingMode.text,
+ }
+
+ const { rerender } = render()
+ expect(screen.getByText('Test')).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('Test')).toBeInTheDocument()
+ })
+
+ it('Actions should render correctly after rerender', () => {
+ const props = {
+ onApplyTemplate: jest.fn(),
+ handleShowTemplateDetails: jest.fn(),
+ showMoreOperations: true,
+ openEditModal: jest.fn(),
+ handleExportDSL: jest.fn(),
+ handleDelete: jest.fn(),
+ }
+
+ const { rerender } = render()
+ expect(screen.getByText('datasetPipeline.operations.choose')).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('datasetPipeline.operations.choose')).toBeInTheDocument()
+ })
+
+ it('Operations should render correctly after rerender', () => {
+ const props = {
+ openEditModal: jest.fn(),
+ onDelete: jest.fn(),
+ onExport: jest.fn(),
+ }
+
+ const { rerender } = render()
+ expect(screen.getByText('datasetPipeline.operations.editInfo')).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('datasetPipeline.operations.editInfo')).toBeInTheDocument()
+ })
+
+ it('EditPipelineInfo should render correctly after rerender', () => {
+ const props = {
+ onClose: jest.fn(),
+ pipeline: createMockPipeline(),
+ }
+
+ const { rerender } = render()
+ expect(screen.getByText('datasetPipeline.editPipelineInfo')).toBeInTheDocument()
+
+ rerender()
+ expect(screen.getByText('datasetPipeline.editPipelineInfo')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.spec.tsx
new file mode 100644
index 0000000000..8876d8f483
--- /dev/null
+++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.spec.tsx
@@ -0,0 +1,786 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import Details from './index'
+import type { PipelineTemplateByIdResponse } from '@/models/pipeline'
+import { ChunkingMode } from '@/models/datasets'
+import type { Edge, Node, Viewport } from 'reactflow'
+
+// Mock usePipelineTemplateById hook
+let mockPipelineTemplateData: PipelineTemplateByIdResponse | undefined
+let mockIsLoading = false
+
+jest.mock('@/service/use-pipeline', () => ({
+ usePipelineTemplateById: (params: { template_id: string; type: 'customized' | 'built-in' }, enabled: boolean) => ({
+ data: enabled ? mockPipelineTemplateData : undefined,
+ isLoading: mockIsLoading,
+ }),
+}))
+
+// Mock WorkflowPreview component to avoid deep dependencies
+jest.mock('@/app/components/workflow/workflow-preview', () => ({
+ __esModule: true,
+ default: ({ nodes, edges, viewport, className }: {
+ nodes: Node[]
+ edges: Edge[]
+ viewport: Viewport
+ className?: string
+ }) => (
+
+ WorkflowPreview
+
+ ),
+}))
+
+// Factory function for creating mock pipeline template response
+const createMockPipelineTemplate = (
+ overrides: Partial = {},
+): PipelineTemplateByIdResponse => ({
+ id: 'test-template-id',
+ name: 'Test Pipeline Template',
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ description: 'Test pipeline description for testing purposes',
+ chunk_structure: ChunkingMode.text,
+ export_data: '{}',
+ graph: {
+ nodes: [
+ { id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
+ ] as unknown as Node[],
+ edges: [] as Edge[],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ created_by: 'Test Author',
+ ...overrides,
+})
+
+// Default props factory
+const createDefaultProps = () => ({
+ id: 'test-id',
+ type: 'built-in' as const,
+ onApplyTemplate: jest.fn(),
+ onClose: jest.fn(),
+})
+
+describe('Details', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockPipelineTemplateData = undefined
+ mockIsLoading = false
+ })
+
+ /**
+ * Loading State Tests
+ * Tests for component behavior when data is loading or undefined
+ */
+ describe('Loading State', () => {
+ it('should render Loading component when pipelineTemplateInfo is undefined', () => {
+ mockPipelineTemplateData = undefined
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ // Loading component renders a spinner SVG with spin-animation class
+ const spinner = container.querySelector('.spin-animation')
+ expect(spinner).toBeInTheDocument()
+ })
+
+ it('should render Loading component when data is still loading', () => {
+ mockIsLoading = true
+ mockPipelineTemplateData = undefined
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ // Loading component renders a spinner SVG with spin-animation class
+ const spinner = container.querySelector('.spin-animation')
+ expect(spinner).toBeInTheDocument()
+ })
+
+ it('should not render main content while loading', () => {
+ mockPipelineTemplateData = undefined
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
+ expect(screen.queryByText('datasetPipeline.operations.useTemplate')).not.toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Rendering Tests
+ * Tests for correct rendering when data is available
+ */
+ describe('Rendering', () => {
+ it('should render without crashing when data is available', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should render the main container with flex layout', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ const mainContainer = container.firstChild as HTMLElement
+ expect(mainContainer).toHaveClass('flex')
+ expect(mainContainer).toHaveClass('h-full')
+ })
+
+ it('should render WorkflowPreview component', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should pass graph data to WorkflowPreview', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ graph: {
+ nodes: [
+ { id: '1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
+ { id: '2', type: 'custom', position: { x: 100, y: 100 }, data: {} },
+ ] as unknown as Node[],
+ edges: [
+ { id: 'e1', source: '1', target: '2' },
+ ] as unknown as Edge[],
+ viewport: { x: 10, y: 20, zoom: 1.5 },
+ },
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ const preview = screen.getByTestId('workflow-preview')
+ expect(preview).toHaveAttribute('data-nodes-count', '2')
+ expect(preview).toHaveAttribute('data-edges-count', '1')
+ expect(preview).toHaveAttribute('data-viewport-zoom', '1.5')
+ })
+
+ it('should render template name', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: 'My Test Pipeline' })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('My Test Pipeline')).toBeInTheDocument()
+ })
+
+ it('should render template description', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ description: 'This is a test description' })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('This is a test description')).toBeInTheDocument()
+ })
+
+ it('should render created_by information when available', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ created_by: 'John Doe' })
+ const props = createDefaultProps()
+
+ render( )
+
+ // The translation key includes the author
+ expect(screen.getByText('datasetPipeline.details.createdBy')).toBeInTheDocument()
+ })
+
+ it('should not render created_by when not available', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ created_by: '' })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.queryByText(/createdBy/)).not.toBeInTheDocument()
+ })
+
+ it('should render "Use Template" button', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('datasetPipeline.operations.useTemplate')).toBeInTheDocument()
+ })
+
+ it('should render close button', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const closeButton = screen.getByRole('button', { name: '' })
+ expect(closeButton).toBeInTheDocument()
+ })
+
+ it('should render structure section title', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
+ })
+
+ it('should render structure tooltip', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ // Tooltip component should be rendered
+ expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Event Handler Tests
+ * Tests for user interactions and callback functions
+ */
+ describe('Event Handlers', () => {
+ it('should call onApplyTemplate when "Use Template" button is clicked', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
+ fireEvent.click(useTemplateButton!)
+
+ expect(props.onApplyTemplate).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClose when close button is clicked', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ // Find the close button (the one with RiCloseLine icon)
+ const closeButton = container.querySelector('button.absolute.right-4')
+ fireEvent.click(closeButton!)
+
+ expect(props.onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call handlers on multiple clicks (each click should trigger once)', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
+ fireEvent.click(useTemplateButton!)
+ fireEvent.click(useTemplateButton!)
+ fireEvent.click(useTemplateButton!)
+
+ expect(props.onApplyTemplate).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ /**
+ * Props Variations Tests
+ * Tests for different prop combinations
+ */
+ describe('Props Variations', () => {
+ it('should handle built-in type', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = { ...createDefaultProps(), type: 'built-in' as const }
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle customized type', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = { ...createDefaultProps(), type: 'customized' as const }
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle different template IDs', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = { ...createDefaultProps(), id: 'unique-template-123' }
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * App Icon Memoization Tests
+ * Tests for the useMemo logic that computes appIcon
+ */
+ describe('App Icon Memoization', () => {
+ it('should use default emoji icon when pipelineTemplateInfo is undefined', () => {
+ mockPipelineTemplateData = undefined
+ const props = createDefaultProps()
+
+ render( )
+
+ // Loading state - no AppIcon rendered
+ expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
+ })
+
+ it('should handle emoji icon type', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#E6F4FF',
+ icon_url: '',
+ },
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ // AppIcon should be rendered with emoji
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle image icon type', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ icon_info: {
+ icon_type: 'image',
+ icon: 'file-id-123',
+ icon_background: '',
+ icon_url: 'https://example.com/image.png',
+ },
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle image icon type with empty url and icon (fallback branch)', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ icon_info: {
+ icon_type: 'image',
+ icon: '', // empty string - triggers || '' fallback
+ icon_background: '',
+ icon_url: '', // empty string - triggers || '' fallback
+ },
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ // Component should still render without errors
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle missing icon properties gracefully', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '',
+ icon_background: '',
+ icon_url: '',
+ },
+ })
+ const props = createDefaultProps()
+
+ expect(() => render( )).not.toThrow()
+ })
+ })
+
+ /**
+ * Chunk Structure Tests
+ * Tests for different chunk_structure values and ChunkStructureCard rendering
+ */
+ describe('Chunk Structure', () => {
+ it('should render ChunkStructureCard for text chunk structure', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ chunk_structure: ChunkingMode.text,
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ // ChunkStructureCard should be rendered
+ expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
+ // General option title
+ expect(screen.getByText('General')).toBeInTheDocument()
+ })
+
+ it('should render ChunkStructureCard for parentChild chunk structure', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ chunk_structure: ChunkingMode.parentChild,
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('Parent-Child')).toBeInTheDocument()
+ })
+
+ it('should render ChunkStructureCard for qa chunk structure', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ chunk_structure: ChunkingMode.qa,
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('Q&A')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Edge Cases Tests
+ * Tests for boundary conditions and unusual inputs
+ */
+ describe('Edge Cases', () => {
+ it('should handle empty name', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: '' })
+ const props = createDefaultProps()
+
+ expect(() => render( )).not.toThrow()
+ })
+
+ it('should handle empty description', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ description: '' })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle very long name', () => {
+ const longName = 'A'.repeat(200)
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: longName })
+ const props = createDefaultProps()
+
+ render( )
+
+ const nameElement = screen.getByText(longName)
+ expect(nameElement).toBeInTheDocument()
+ expect(nameElement).toHaveClass('truncate')
+ })
+
+ it('should handle very long description', () => {
+ const longDesc = 'B'.repeat(1000)
+ mockPipelineTemplateData = createMockPipelineTemplate({ description: longDesc })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText(longDesc)).toBeInTheDocument()
+ })
+
+ it('should handle special characters in name', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ name: 'Test <>&"\'Pipeline @#$%^&*()',
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('Test <>&"\'Pipeline @#$%^&*()')).toBeInTheDocument()
+ })
+
+ it('should handle unicode characters', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ name: 'ๆต่ฏ็ฎก้ ๐ ใในใ',
+ description: '่ฟๆฏไธไธชๆต่ฏๆ่ฟฐ ๆฅๆฌ่ชใในใ',
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ expect(screen.getByText('ๆต่ฏ็ฎก้ ๐ ใในใ')).toBeInTheDocument()
+ expect(screen.getByText('่ฟๆฏไธไธชๆต่ฏๆ่ฟฐ ๆฅๆฌ่ชใในใ')).toBeInTheDocument()
+ })
+
+ it('should handle empty graph nodes and edges', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ })
+ const props = createDefaultProps()
+
+ render( )
+
+ const preview = screen.getByTestId('workflow-preview')
+ expect(preview).toHaveAttribute('data-nodes-count', '0')
+ expect(preview).toHaveAttribute('data-edges-count', '0')
+ })
+ })
+
+ /**
+ * Component Memoization Tests
+ * Tests for React.memo behavior
+ */
+ describe('Component Memoization', () => {
+ it('should render correctly after rerender with same props', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { rerender } = render( )
+
+ expect(screen.getByText('Test Pipeline Template')).toBeInTheDocument()
+
+ rerender( )
+
+ expect(screen.getByText('Test Pipeline Template')).toBeInTheDocument()
+ })
+
+ it('should update when id prop changes', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: 'First Template' })
+ const props = createDefaultProps()
+
+ const { rerender } = render( )
+
+ expect(screen.getByText('First Template')).toBeInTheDocument()
+
+ // Change the id prop which should trigger a rerender
+ // Update mock data for the new id
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: 'Second Template' })
+ rerender( )
+
+ expect(screen.getByText('Second Template')).toBeInTheDocument()
+ })
+
+ it('should handle callback reference changes', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { rerender } = render( )
+
+ const newOnApplyTemplate = jest.fn()
+ rerender( )
+
+ const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
+ fireEvent.click(useTemplateButton!)
+
+ expect(newOnApplyTemplate).toHaveBeenCalledTimes(1)
+ expect(props.onApplyTemplate).not.toHaveBeenCalled()
+ })
+ })
+
+ /**
+ * Component Structure Tests
+ * Tests for DOM structure and layout
+ */
+ describe('Component Structure', () => {
+ it('should have left panel for workflow preview', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ const leftPanel = container.querySelector('.grow.items-center.justify-center')
+ expect(leftPanel).toBeInTheDocument()
+ })
+
+ it('should have right panel with fixed width', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { container } = render( )
+
+ const rightPanel = container.querySelector('.w-\\[360px\\]')
+ expect(rightPanel).toBeInTheDocument()
+ })
+
+ it('should have primary button variant for Use Template', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const button = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
+ // Button should have primary styling
+ expect(button).toBeInTheDocument()
+ })
+
+ it('should have title attribute for truncation tooltip', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: 'My Pipeline Name' })
+ const props = createDefaultProps()
+
+ render( )
+
+ const nameElement = screen.getByText('My Pipeline Name')
+ expect(nameElement).toHaveAttribute('title', 'My Pipeline Name')
+ })
+
+ it('should have title attribute on created_by for truncation', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ created_by: 'Author Name' })
+ const props = createDefaultProps()
+
+ render( )
+
+ const createdByElement = screen.getByText('datasetPipeline.details.createdBy')
+ expect(createdByElement).toHaveAttribute('title', 'Author Name')
+ })
+ })
+
+ /**
+ * Component Lifecycle Tests
+ * Tests for mount/unmount behavior
+ */
+ describe('Component Lifecycle', () => {
+ it('should mount without errors', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ expect(() => render( )).not.toThrow()
+ })
+
+ it('should unmount without errors', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ const { unmount } = render( )
+
+ expect(() => unmount()).not.toThrow()
+ })
+
+ it('should handle rapid mount/unmount cycles', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ for (let i = 0; i < 5; i++) {
+ const { unmount } = render( )
+ unmount()
+ }
+
+ expect(true).toBe(true)
+ })
+
+ it('should transition from loading to loaded state', () => {
+ mockPipelineTemplateData = undefined
+ const props = createDefaultProps()
+
+ const { rerender, container } = render( )
+
+ // Loading component renders a spinner SVG with spin-animation class
+ const spinner = container.querySelector('.spin-animation')
+ expect(spinner).toBeInTheDocument()
+
+ // Simulate data loaded - need to change props to trigger rerender with React.memo
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ rerender( )
+
+ expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Styling Tests
+ * Tests for CSS classes and visual styling
+ */
+ describe('Styling', () => {
+ it('should apply overflow-hidden rounded-2xl to WorkflowPreview container', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const preview = screen.getByTestId('workflow-preview')
+ expect(preview).toHaveClass('overflow-hidden')
+ expect(preview).toHaveClass('rounded-2xl')
+ })
+
+ it('should apply correct typography classes to template name', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const nameElement = screen.getByText('Test Pipeline Template')
+ expect(nameElement).toHaveClass('system-md-semibold')
+ expect(nameElement).toHaveClass('text-text-secondary')
+ })
+
+ it('should apply correct styling to description', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const description = screen.getByText('Test pipeline description for testing purposes')
+ expect(description).toHaveClass('system-sm-regular')
+ expect(description).toHaveClass('text-text-secondary')
+ })
+
+ it('should apply correct styling to structure title', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = createDefaultProps()
+
+ render( )
+
+ const structureTitle = screen.getByText('datasetPipeline.details.structure')
+ expect(structureTitle).toHaveClass('system-sm-semibold-uppercase')
+ expect(structureTitle).toHaveClass('text-text-secondary')
+ })
+ })
+
+ /**
+ * API Hook Integration Tests
+ * Tests for usePipelineTemplateById hook behavior
+ */
+ describe('API Hook Integration', () => {
+ it('should pass correct params to usePipelineTemplateById for built-in type', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = { ...createDefaultProps(), id: 'test-id-123', type: 'built-in' as const }
+
+ render( )
+
+ // The hook should be called with the correct parameters
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should pass correct params to usePipelineTemplateById for customized type', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate()
+ const props = { ...createDefaultProps(), id: 'custom-id-456', type: 'customized' as const }
+
+ render( )
+
+ expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
+ })
+
+ it('should handle data refetch on id change', () => {
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: 'First Template' })
+ const props = createDefaultProps()
+
+ const { rerender } = render( )
+
+ expect(screen.getByText('First Template')).toBeInTheDocument()
+
+ // Change id and update mock data
+ mockPipelineTemplateData = createMockPipelineTemplate({ name: 'Second Template' })
+ rerender( )
+
+ expect(screen.getByText('Second Template')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx
new file mode 100644
index 0000000000..77735555ed
--- /dev/null
+++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx
@@ -0,0 +1,965 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import TemplateCard from './index'
+import type { PipelineTemplate, PipelineTemplateByIdResponse } from '@/models/pipeline'
+import { ChunkingMode } from '@/models/datasets'
+
+// Mock Next.js router
+const mockPush = jest.fn()
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+let mockCreateDataset: jest.Mock
+let mockDeleteTemplate: jest.Mock
+let mockExportTemplateDSL: jest.Mock
+let mockInvalidCustomizedTemplateList: jest.Mock
+let mockInvalidDatasetList: jest.Mock
+let mockHandleCheckPluginDependencies: jest.Mock
+let mockIsExporting = false
+
+// Mock service hooks
+let mockPipelineTemplateByIdData: PipelineTemplateByIdResponse | undefined
+let mockRefetch: jest.Mock
+
+jest.mock('@/service/use-pipeline', () => ({
+ usePipelineTemplateById: () => ({
+ data: mockPipelineTemplateByIdData,
+ refetch: mockRefetch,
+ }),
+ useDeleteTemplate: () => ({
+ mutateAsync: mockDeleteTemplate,
+ }),
+ useExportTemplateDSL: () => ({
+ mutateAsync: mockExportTemplateDSL,
+ isPending: mockIsExporting,
+ }),
+ useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
+}))
+
+jest.mock('@/service/knowledge/use-create-dataset', () => ({
+ useCreatePipelineDatasetFromCustomized: () => ({
+ mutateAsync: mockCreateDataset,
+ }),
+}))
+
+jest.mock('@/service/knowledge/use-dataset', () => ({
+ useInvalidDatasetList: () => mockInvalidDatasetList,
+}))
+
+jest.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
+ usePluginDependencies: () => ({
+ handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
+ }),
+}))
+
+// Mock downloadFile
+const mockDownloadFile = jest.fn()
+jest.mock('@/utils/format', () => ({
+ downloadFile: (params: { data: Blob; fileName: string }) => mockDownloadFile(params),
+}))
+
+// Mock trackEvent
+const mockTrackEvent = jest.fn()
+jest.mock('@/app/components/base/amplitude', () => ({
+ trackEvent: (name: string, params: Record) => mockTrackEvent(name, params),
+}))
+
+// Mock child components to simplify testing
+jest.mock('./content', () => ({
+ __esModule: true,
+ default: ({ name, description, iconInfo, chunkStructure }: {
+ name: string
+ description: string
+ iconInfo: { icon_type: string }
+ chunkStructure: string
+ }) => (
+
+ {name}
+ {description}
+ {iconInfo.icon_type}
+ {chunkStructure}
+
+ ),
+}))
+
+jest.mock('./actions', () => ({
+ __esModule: true,
+ default: ({
+ onApplyTemplate,
+ handleShowTemplateDetails,
+ showMoreOperations,
+ openEditModal,
+ handleExportDSL,
+ handleDelete,
+ }: {
+ onApplyTemplate: () => void
+ handleShowTemplateDetails: () => void
+ showMoreOperations: boolean
+ openEditModal: () => void
+ handleExportDSL: () => void
+ handleDelete: () => void
+ }) => (
+
+
+
+
+
+
+
+ ),
+}))
+
+jest.mock('./details', () => ({
+ __esModule: true,
+ default: ({ id, type, onClose, onApplyTemplate }: {
+ id: string
+ type: string
+ onClose: () => void
+ onApplyTemplate: () => void
+ }) => (
+
+ {id}
+ {type}
+
+
+
+ ),
+}))
+
+jest.mock('./edit-pipeline-info', () => ({
+ __esModule: true,
+ default: ({ pipeline, onClose }: {
+ pipeline: PipelineTemplate
+ onClose: () => void
+ }) => (
+
+ {pipeline.id}
+
+
+ ),
+}))
+
+// Factory function for creating mock pipeline template
+const createMockPipeline = (overrides: Partial = {}): PipelineTemplate => ({
+ id: 'test-pipeline-id',
+ name: 'Test Pipeline',
+ description: 'Test pipeline description',
+ icon: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ position: 1,
+ chunk_structure: ChunkingMode.text,
+ ...overrides,
+})
+
+// Factory function for creating mock pipeline template by id response
+const createMockPipelineByIdResponse = (
+ overrides: Partial = {},
+): PipelineTemplateByIdResponse => ({
+ id: 'test-pipeline-id',
+ name: 'Test Pipeline',
+ description: 'Test pipeline description',
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ },
+ chunk_structure: ChunkingMode.text,
+ export_data: 'yaml_content_here',
+ graph: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ created_by: 'Test Author',
+ ...overrides,
+})
+
+// Default props factory
+const createDefaultProps = () => ({
+ pipeline: createMockPipeline(),
+ type: 'built-in' as const,
+ showMoreOperations: true,
+})
+
+describe('TemplateCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockPipelineTemplateByIdData = undefined
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn()
+ mockDeleteTemplate = jest.fn()
+ mockExportTemplateDSL = jest.fn()
+ mockInvalidCustomizedTemplateList = jest.fn()
+ mockInvalidDatasetList = jest.fn()
+ mockHandleCheckPluginDependencies = jest.fn()
+ mockIsExporting = false
+ })
+
+ /**
+ * Rendering Tests
+ * Tests for basic component rendering
+ */
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+ expect(screen.getByTestId('actions')).toBeInTheDocument()
+ })
+
+ it('should render Content component with correct props', () => {
+ const pipeline = createMockPipeline({
+ name: 'My Pipeline',
+ description: 'My description',
+ chunk_structure: ChunkingMode.qa,
+ })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent('My Pipeline')
+ expect(screen.getByTestId('content-description')).toHaveTextContent('My description')
+ expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(ChunkingMode.qa)
+ })
+
+ it('should render Actions component with showMoreOperations=true by default', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ const actions = screen.getByTestId('actions')
+ expect(actions).toHaveAttribute('data-show-more', 'true')
+ })
+
+ it('should render Actions component with showMoreOperations=false when specified', () => {
+ const props = { ...createDefaultProps(), showMoreOperations: false }
+
+ render()
+
+ const actions = screen.getByTestId('actions')
+ expect(actions).toHaveAttribute('data-show-more', 'false')
+ })
+
+ it('should have correct container styling', () => {
+ const props = createDefaultProps()
+
+ const { container } = render()
+
+ const card = container.firstChild as HTMLElement
+ expect(card).toHaveClass('group')
+ expect(card).toHaveClass('relative')
+ expect(card).toHaveClass('flex')
+ expect(card).toHaveClass('h-[132px]')
+ expect(card).toHaveClass('cursor-pointer')
+ expect(card).toHaveClass('rounded-xl')
+ })
+ })
+
+ /**
+ * Props Variations Tests
+ * Tests for different prop combinations
+ */
+ describe('Props Variations', () => {
+ it('should handle built-in type', () => {
+ const props = { ...createDefaultProps(), type: 'built-in' as const }
+
+ render()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+ })
+
+ it('should handle customized type', () => {
+ const props = { ...createDefaultProps(), type: 'customized' as const }
+
+ render()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+ })
+
+ it('should handle different pipeline data', () => {
+ const pipeline = createMockPipeline({
+ id: 'unique-id-123',
+ name: 'Unique Pipeline',
+ description: 'Unique description',
+ chunk_structure: ChunkingMode.parentChild,
+ })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent('Unique Pipeline')
+ expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(ChunkingMode.parentChild)
+ })
+
+ it('should handle image icon type', () => {
+ const pipeline = createMockPipeline({
+ icon: {
+ icon_type: 'image',
+ icon: 'file-id',
+ icon_background: '',
+ icon_url: 'https://example.com/image.png',
+ },
+ })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ expect(screen.getByTestId('content-icon-type')).toHaveTextContent('image')
+ })
+ })
+
+ /**
+ * State Management Tests
+ * Tests for modal state (showEditModal, showDeleteConfirm, showDetailModal)
+ */
+ describe('State Management', () => {
+ it('should not show edit modal initially', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
+ })
+
+ it('should show edit modal when openEditModal is called', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('edit-modal-btn'))
+
+ expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
+ })
+
+ it('should close edit modal when onClose is called', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('edit-modal-btn'))
+ expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('edit-close-btn'))
+ expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
+ })
+
+ it('should not show delete confirm initially', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
+ })
+
+ it('should show delete confirm when handleDelete is called', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('delete-btn'))
+
+ expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
+ })
+
+ it('should not show details modal initially', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
+ })
+
+ it('should show details modal when handleShowTemplateDetails is called', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+
+ expect(screen.getByTestId('details-modal')).toBeInTheDocument()
+ })
+
+ it('should close details modal when onClose is called', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+ expect(screen.getByTestId('details-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('details-close-btn'))
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
+ })
+
+ it('should pass correct props to details modal', () => {
+ const pipeline = createMockPipeline({ id: 'detail-test-id' })
+ const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
+
+ render()
+
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+
+ expect(screen.getByTestId('details-id')).toHaveTextContent('detail-test-id')
+ expect(screen.getByTestId('details-type')).toHaveTextContent('customized')
+ })
+ })
+
+ /**
+ * Event Handlers Tests
+ * Tests for callback functions and user interactions
+ */
+ describe('Event Handlers', () => {
+ describe('handleUseTemplate', () => {
+ it('should call getPipelineTemplateInfo when apply template is clicked', async () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call createDataset when pipelineTemplateInfo is not available', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: null })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+
+ // createDataset should not be called when pipelineTemplateInfo is null
+ expect(mockCreateDataset).not.toHaveBeenCalled()
+ })
+
+ it('should call createDataset with correct yaml_content', async () => {
+ const pipelineResponse = createMockPipelineByIdResponse({ export_data: 'test-yaml-content' })
+ mockRefetch = jest.fn().mockResolvedValue({ data: pipelineResponse })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockCreateDataset).toHaveBeenCalledWith(
+ { yaml_content: 'test-yaml-content' },
+ expect.any(Object),
+ )
+ })
+ })
+
+ it('should invalidate list, check plugin dependencies, and navigate on success', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: 'new-pipeline-id' })
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockInvalidDatasetList).toHaveBeenCalled()
+ expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('new-pipeline-id', true)
+ expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-id/pipeline')
+ })
+ })
+
+ it('should track event on successful dataset creation', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: 'new-pipeline-id' })
+ })
+ const pipeline = createMockPipeline({ id: 'track-test-id', name: 'Track Test Pipeline' })
+ const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockTrackEvent).toHaveBeenCalledWith('create_datasets_with_pipeline', {
+ template_name: 'Track Test Pipeline',
+ template_id: 'track-test-id',
+ template_type: 'customized',
+ })
+ })
+ })
+
+ it('should not call handleCheckPluginDependencies when pipeline_id is not present', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: null })
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should call onError callback when createDataset fails', async () => {
+ const onErrorSpy = jest.fn()
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
+ onErrorSpy()
+ options.onError()
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockCreateDataset).toHaveBeenCalled()
+ expect(onErrorSpy).toHaveBeenCalled()
+ })
+
+ // Should not navigate on error
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('handleExportDSL', () => {
+ it('should call exportPipelineDSL with pipeline id', async () => {
+ const pipeline = createMockPipeline({ id: 'export-test-id' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ fireEvent.click(screen.getByTestId('export-dsl-btn'))
+
+ await waitFor(() => {
+ expect(mockExportTemplateDSL).toHaveBeenCalledWith('export-test-id', expect.any(Object))
+ })
+ })
+
+ it('should not call exportPipelineDSL when already exporting', async () => {
+ mockIsExporting = true
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('export-dsl-btn'))
+
+ await waitFor(() => {
+ expect(mockExportTemplateDSL).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should download file on export success', async () => {
+ mockExportTemplateDSL = jest.fn().mockImplementation((_id, options) => {
+ options.onSuccess({ data: 'exported-yaml-content' })
+ })
+ const pipeline = createMockPipeline({ name: 'Export Pipeline' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ fireEvent.click(screen.getByTestId('export-dsl-btn'))
+
+ await waitFor(() => {
+ expect(mockDownloadFile).toHaveBeenCalledWith({
+ data: expect.any(Blob),
+ fileName: 'Export Pipeline.pipeline',
+ })
+ })
+ })
+
+ it('should call onError callback on export failure', async () => {
+ const onErrorSpy = jest.fn()
+ mockExportTemplateDSL = jest.fn().mockImplementation((_id, options) => {
+ onErrorSpy()
+ options.onError()
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('export-dsl-btn'))
+
+ await waitFor(() => {
+ expect(mockExportTemplateDSL).toHaveBeenCalled()
+ expect(onErrorSpy).toHaveBeenCalled()
+ })
+
+ // Should not download file on error
+ expect(mockDownloadFile).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('handleDelete', () => {
+ it('should call deletePipeline on confirm', async () => {
+ mockDeleteTemplate = jest.fn().mockImplementation((_id, options) => {
+ options.onSuccess()
+ })
+ const pipeline = createMockPipeline({ id: 'delete-test-id' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ fireEvent.click(screen.getByTestId('delete-btn'))
+ expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
+
+ // Find and click confirm button
+ const confirmButton = screen.getByText('common.operation.confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockDeleteTemplate).toHaveBeenCalledWith('delete-test-id', expect.any(Object))
+ })
+ })
+
+ it('should invalidate customized template list and close confirm on success', async () => {
+ mockDeleteTemplate = jest.fn().mockImplementation((_id, options) => {
+ options.onSuccess()
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('delete-btn'))
+ const confirmButton = screen.getByText('common.operation.confirm')
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
+ expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should close delete confirm on cancel', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('delete-btn'))
+ expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
+
+ const cancelButton = screen.getByText('common.operation.cancel')
+ fireEvent.click(cancelButton)
+
+ expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ /**
+ * Callback Stability Tests
+ * Tests for useCallback memoization
+ */
+ describe('Callback Stability', () => {
+ it('should maintain stable handleShowTemplateDetails reference', () => {
+ const props = createDefaultProps()
+
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+ expect(screen.getByTestId('details-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('details-close-btn'))
+ rerender()
+
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+ expect(screen.getByTestId('details-modal')).toBeInTheDocument()
+ })
+
+ it('should maintain stable openEditModal reference', () => {
+ const props = createDefaultProps()
+
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByTestId('edit-modal-btn'))
+ expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('edit-close-btn'))
+ rerender()
+
+ fireEvent.click(screen.getByTestId('edit-modal-btn'))
+ expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Component Memoization Tests
+ * Tests for React.memo behavior
+ */
+ describe('Component Memoization', () => {
+ it('should render correctly after rerender with same props', () => {
+ const props = createDefaultProps()
+
+ const { rerender } = render()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+ })
+
+ it('should update when pipeline prop changes', () => {
+ const props = createDefaultProps()
+
+ const { rerender } = render()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent('Test Pipeline')
+
+ const newPipeline = createMockPipeline({ name: 'Updated Pipeline' })
+ rerender()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent('Updated Pipeline')
+ })
+
+ it('should update when type prop changes', () => {
+ const props = createDefaultProps()
+
+ const { rerender } = render()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+ })
+
+ it('should update when showMoreOperations prop changes', () => {
+ const props = createDefaultProps()
+
+ const { rerender } = render()
+
+ expect(screen.getByTestId('actions')).toHaveAttribute('data-show-more', 'true')
+
+ rerender()
+
+ expect(screen.getByTestId('actions')).toHaveAttribute('data-show-more', 'false')
+ })
+ })
+
+ /**
+ * Edge Cases Tests
+ * Tests for boundary conditions and error handling
+ */
+ describe('Edge Cases', () => {
+ it('should handle empty pipeline name', () => {
+ const pipeline = createMockPipeline({ name: '' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ expect(screen.getByTestId('content-name')).toHaveTextContent('')
+ })
+
+ it('should handle empty pipeline description', () => {
+ const pipeline = createMockPipeline({ description: '' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ expect(() => render()).not.toThrow()
+ expect(screen.getByTestId('content-description')).toHaveTextContent('')
+ })
+
+ it('should handle very long pipeline name', () => {
+ const longName = 'A'.repeat(200)
+ const pipeline = createMockPipeline({ name: longName })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent(longName)
+ })
+
+ it('should handle special characters in name', () => {
+ const pipeline = createMockPipeline({ name: 'Test <>&"\'Pipeline @#$%' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent('Test <>&"\'Pipeline @#$%')
+ })
+
+ it('should handle unicode characters', () => {
+ const pipeline = createMockPipeline({ name: 'ๆต่ฏ็ฎก้ ๐ ใในใ' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ expect(screen.getByTestId('content-name')).toHaveTextContent('ๆต่ฏ็ฎก้ ๐ ใในใ')
+ })
+
+ it('should handle all chunk structure types', () => {
+ const chunkModes = [ChunkingMode.text, ChunkingMode.parentChild, ChunkingMode.qa]
+
+ chunkModes.forEach((mode) => {
+ const pipeline = createMockPipeline({ chunk_structure: mode })
+ const props = { ...createDefaultProps(), pipeline }
+
+ const { unmount } = render()
+
+ expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(mode)
+ unmount()
+ })
+ })
+ })
+
+ /**
+ * Component Lifecycle Tests
+ * Tests for mount/unmount behavior
+ */
+ describe('Component Lifecycle', () => {
+ it('should mount without errors', () => {
+ const props = createDefaultProps()
+
+ expect(() => render()).not.toThrow()
+ })
+
+ it('should unmount without errors', () => {
+ const props = createDefaultProps()
+
+ const { unmount } = render()
+
+ expect(() => unmount()).not.toThrow()
+ })
+
+ it('should handle rapid mount/unmount cycles', () => {
+ const props = createDefaultProps()
+
+ for (let i = 0; i < 5; i++) {
+ const { unmount } = render()
+ unmount()
+ }
+
+ expect(true).toBe(true)
+ })
+ })
+
+ /**
+ * Modal Integration Tests
+ * Tests for modal interactions and nested callbacks
+ */
+ describe('Modal Integration', () => {
+ it('should pass correct pipeline to edit modal', () => {
+ const pipeline = createMockPipeline({ id: 'modal-test-id' })
+ const props = { ...createDefaultProps(), pipeline }
+
+ render()
+
+ fireEvent.click(screen.getByTestId('edit-modal-btn'))
+
+ expect(screen.getByTestId('edit-pipeline-id')).toHaveTextContent('modal-test-id')
+ })
+
+ it('should be able to apply template from details modal', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess({ dataset_id: 'new-id', pipeline_id: 'new-pipeline' })
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+ fireEvent.click(screen.getByTestId('details-apply-btn'))
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled()
+ expect(mockCreateDataset).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle multiple modals sequentially', () => {
+ const props = createDefaultProps()
+
+ render()
+
+ // Open edit modal
+ fireEvent.click(screen.getByTestId('edit-modal-btn'))
+ expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
+
+ // Close edit modal
+ fireEvent.click(screen.getByTestId('edit-close-btn'))
+ expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
+
+ // Open details modal
+ fireEvent.click(screen.getByTestId('show-details-btn'))
+ expect(screen.getByTestId('details-modal')).toBeInTheDocument()
+
+ // Close details modal
+ fireEvent.click(screen.getByTestId('details-close-btn'))
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
+
+ // Open delete confirm
+ fireEvent.click(screen.getByTestId('delete-btn'))
+ expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * API Integration Tests
+ * Tests for service hook interactions
+ */
+ describe('API Integration', () => {
+ it('should initialize hooks with correct parameters', () => {
+ const pipeline = createMockPipeline({ id: 'hook-test-id' })
+ const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
+
+ render()
+
+ expect(screen.getByTestId('content')).toBeInTheDocument()
+ })
+
+ it('should handle async operations correctly', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation(async (_req, options) => {
+ await new Promise(resolve => setTimeout(resolve, 10))
+ options.onSuccess({ dataset_id: 'async-test-id', pipeline_id: 'async-pipeline' })
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith('/datasets/async-test-id/pipeline')
+ })
+ })
+
+ it('should handle concurrent API calls gracefully', async () => {
+ mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
+ mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
+ options.onSuccess({ dataset_id: 'concurrent-id', pipeline_id: 'concurrent-pipeline' })
+ })
+ const props = createDefaultProps()
+
+ render()
+
+ // Trigger multiple clicks
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+ fireEvent.click(screen.getByTestId('apply-template-btn'))
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+ })
+ })
+})