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() + }) + }) + }) +})