diff --git a/web/app/components/datasets/create/file-preview/index.spec.tsx b/web/app/components/datasets/create/file-preview/index.spec.tsx new file mode 100644 index 0000000000..b7d7b489b4 --- /dev/null +++ b/web/app/components/datasets/create/file-preview/index.spec.tsx @@ -0,0 +1,873 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import FilePreview from './index' +import type { CustomFile as File } from '@/models/datasets' +import { fetchFilePreview } from '@/service/common' + +// Mock the fetchFilePreview service +jest.mock('@/service/common', () => ({ + fetchFilePreview: jest.fn(), +})) + +const mockFetchFilePreview = fetchFilePreview as jest.MockedFunction + +// Factory function to create mock file objects +const createMockFile = (overrides: Partial = {}): File => { + const file = new window.File(['test content'], 'test-file.txt', { + type: 'text/plain', + }) as File + return Object.assign(file, { + id: 'file-123', + extension: 'txt', + mime_type: 'text/plain', + created_by: 'user-1', + created_at: Date.now(), + ...overrides, + }) +} + +// Helper to render FilePreview with default props +const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => { + const defaultProps = { + file: createMockFile(), + hidePreview: jest.fn(), + ...props, + } + return { + ...render(), + props: defaultProps, + } +} + +// Helper to find the loading spinner element +const findLoadingSpinner = (container: HTMLElement) => { + return container.querySelector('.spin-animation') +} + +// ============================================================================ +// FilePreview Component Tests +// ============================================================================ +describe('FilePreview', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default successful API response + mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' }) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', async () => { + // Arrange & Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + }) + + it('should render file preview header', async () => { + // Arrange & Act + renderFilePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + + it('should render close button with XMarkIcon', async () => { + // Arrange & Act + const { container } = renderFilePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + const xMarkIcon = closeButton?.querySelector('svg') + expect(xMarkIcon).toBeInTheDocument() + }) + + it('should render file name without extension', async () => { + // Arrange + const file = createMockFile({ name: 'document.pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + await waitFor(() => { + expect(screen.getByText('document')).toBeInTheDocument() + }) + }) + + it('should render file extension', async () => { + // Arrange + const file = createMockFile({ extension: 'pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('.pdf')).toBeInTheDocument() + }) + + it('should apply correct CSS classes to container', async () => { + // Arrange & Act + const { container } = renderFilePreview() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('h-full') + }) + }) + + // -------------------------------------------------------------------------- + // Loading State Tests + // -------------------------------------------------------------------------- + describe('Loading State', () => { + it('should show loading indicator initially', async () => { + // Arrange - Delay API response to keep loading state + mockFetchFilePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)), + ) + + // Act + const { container } = renderFilePreview() + + // Assert - Loading should be visible initially (using spin-animation class) + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should hide loading indicator after content loads', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' }) + + // Act + const { container } = renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('Loaded content')).toBeInTheDocument() + }) + // Loading should be gone + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + + it('should show loading when file changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' }) + const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' }) + + let resolveFirst: (value: { content: string }) => void + let resolveSecond: (value: { content: string }) => void + + mockFetchFilePreview + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act - Initial render + const { rerender, container } = render( + , + ) + + // First file loading - spinner should be visible + expect(findLoadingSpinner(container)).toBeInTheDocument() + + // Resolve first file + await act(async () => { + resolveFirst({ content: 'Content 1' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Rerender with new file + rerender() + + // Should show loading again + await waitFor(() => { + expect(findLoadingSpinner(container)).toBeInTheDocument() + }) + + // Resolve second file + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // API Call Tests + // -------------------------------------------------------------------------- + describe('API Calls', () => { + it('should call fetchFilePreview with correct fileID', async () => { + // Arrange + const file = createMockFile({ id: 'test-file-id' }) + + // Act + renderFilePreview({ file }) + + // Assert + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' }) + }) + }) + + it('should not call fetchFilePreview when file is undefined', async () => { + // Arrange & Act + renderFilePreview({ file: undefined }) + + // Assert + expect(mockFetchFilePreview).not.toHaveBeenCalled() + }) + + it('should not call fetchFilePreview when file has no id', async () => { + // Arrange + const file = createMockFile({ id: undefined }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(mockFetchFilePreview).not.toHaveBeenCalled() + }) + + it('should call fetchFilePreview again when file changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' }) + }) + + rerender() + + // Assert + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' }) + expect(mockFetchFilePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should handle API success and display content', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('File preview content from API')).toBeInTheDocument() + }) + }) + + it('should handle API error gracefully', async () => { + // Arrange + mockFetchFilePreview.mockRejectedValue(new Error('Network error')) + + // Act + const { container } = renderFilePreview() + + // Assert - Component should not crash, loading may persist + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + // No error thrown, component still rendered + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + + it('should handle empty content response', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: '' }) + + // Act + const { container } = renderFilePreview() + + // Assert - Should still render without loading + await waitFor(() => { + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions Tests + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = renderFilePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + + it('should call hidePreview with event object when clicked', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = renderFilePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + // Assert - onClick receives the event object + expect(hidePreview).toHaveBeenCalled() + expect(hidePreview.mock.calls[0][0]).toBeDefined() + }) + + it('should handle multiple clicks on close button', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = renderFilePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + fireEvent.click(closeButton) + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(3) + }) + }) + + // -------------------------------------------------------------------------- + // State Management Tests + // -------------------------------------------------------------------------- + describe('State Management', () => { + it('should initialize with loading state true', async () => { + // Arrange - Keep loading indefinitely (never resolves) + mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ })) + + // Act + const { container } = renderFilePreview() + + // Assert + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should update previewContent state after successful fetch', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText('New preview content')).toBeInTheDocument() + }) + }) + + it('should reset loading to true when file changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + mockFetchFilePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change file + rerender() + + // Assert - Loading should be shown again + await waitFor(() => { + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + }) + + it('should preserve content until new content loads', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + let resolveSecond: (value: { content: string }) => void + + mockFetchFilePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change file - loading should replace content + rerender() + + // Resolve second fetch + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('file prop', () => { + it('should render correctly with file prop', async () => { + // Arrange + const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('my-document')).toBeInTheDocument() + expect(screen.getByText('.pdf')).toBeInTheDocument() + }) + + it('should render correctly without file prop', async () => { + // Arrange & Act + renderFilePreview({ file: undefined }) + + // Assert - Header should still render + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + + it('should handle file with multiple dots in name', async () => { + // Arrange + const file = createMockFile({ name: 'my.document.v2.pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert - Should join all parts except last with comma + expect(screen.getByText('my,document,v2')).toBeInTheDocument() + }) + + it('should handle file with no extension in name', async () => { + // Arrange + const file = createMockFile({ name: 'README' }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - getFileName returns empty for single segment, but component still renders + const fileNameElement = container.querySelector('.fileName') + expect(fileNameElement).toBeInTheDocument() + // The first span (file name) should be empty + const fileNameSpan = fileNameElement?.querySelector('span:first-child') + expect(fileNameSpan?.textContent).toBe('') + }) + + it('should handle file with empty name', async () => { + // Arrange + const file = createMockFile({ name: '' }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('hidePreview prop', () => { + it('should accept hidePreview callback', async () => { + // Arrange + const hidePreview = jest.fn() + + // Act + renderFilePreview({ hidePreview }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle file with undefined id', async () => { + // Arrange + const file = createMockFile({ id: undefined }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - Should not call API, remain in loading state + expect(mockFetchFilePreview).not.toHaveBeenCalled() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle file with empty string id', async () => { + // Arrange + const file = createMockFile({ id: '' }) + + // Act + renderFilePreview({ file }) + + // Assert - Empty string is falsy, should not call API + expect(mockFetchFilePreview).not.toHaveBeenCalled() + }) + + it('should handle very long file names', async () => { + // Arrange + const longName = `${'a'.repeat(200)}.pdf` + const file = createMockFile({ name: longName }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('a'.repeat(200))).toBeInTheDocument() + }) + + it('should handle file with special characters in name', async () => { + // Arrange + const file = createMockFile({ name: 'file-with_special@#$%.txt' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('file-with_special@#$%')).toBeInTheDocument() + }) + + it('should handle very long preview content', async () => { + // Arrange + const longContent = 'x'.repeat(10000) + mockFetchFilePreview.mockResolvedValue({ content: longContent }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + }) + + it('should handle preview content with special characters safely', async () => { + // Arrange + const specialContent = '\n\t& < > "' + mockFetchFilePreview.mockResolvedValue({ content: specialContent }) + + // Act + const { container } = renderFilePreview() + + // Assert - Should render as text, not execute scripts + await waitFor(() => { + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + // Content is escaped by React, so HTML entities are displayed + expect(contentDiv?.textContent).toContain('alert') + }) + }) + + it('should handle preview content with unicode', async () => { + // Arrange + const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs' + mockFetchFilePreview.mockResolvedValue({ content: unicodeContent }) + + // Act + renderFilePreview() + + // Assert + await waitFor(() => { + expect(screen.getByText(unicodeContent)).toBeInTheDocument() + }) + }) + + it('should handle preview content with newlines', async () => { + // Arrange + const multilineContent = 'Line 1\nLine 2\nLine 3' + mockFetchFilePreview.mockResolvedValue({ content: multilineContent }) + + // Act + const { container } = renderFilePreview() + + // Assert - Content should be in the DOM + await waitFor(() => { + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toContain('Line 1') + expect(contentDiv?.textContent).toContain('Line 2') + expect(contentDiv?.textContent).toContain('Line 3') + }) + }) + + it('should handle null content from API', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string }) + + // Act + const { container } = renderFilePreview() + + // Assert - Should not crash + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Side Effects and Cleanup Tests + // -------------------------------------------------------------------------- + describe('Side Effects and Cleanup', () => { + it('should trigger effect when file prop changes', async () => { + // Arrange + const file1 = createMockFile({ id: 'file-1' }) + const file2 = createMockFile({ id: 'file-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + + rerender() + + // Assert + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should not trigger effect when hidePreview changes', async () => { + // Arrange + const file = createMockFile() + const hidePreview1 = jest.fn() + const hidePreview2 = jest.fn() + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + + rerender() + + // Assert - Should not call API again (file didn't change) + // Note: This depends on useEffect dependency array only including [file] + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + }) + + it('should handle rapid file changes', async () => { + // Arrange + const files = Array.from({ length: 5 }, (_, i) => + createMockFile({ id: `file-${i}` }), + ) + + // Act + const { rerender } = render( + , + ) + + // Rapidly change files + for (let i = 1; i < files.length; i++) + rerender() + + // Assert - Should have called API for each file + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(5) + }) + }) + + it('should handle unmount during loading', async () => { + // Arrange + mockFetchFilePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), + ) + + // Act + const { unmount } = renderFilePreview() + + // Unmount before API resolves + unmount() + + // Assert - No errors should be thrown (React handles state updates on unmounted) + expect(true).toBe(true) + }) + + it('should handle file changing from defined to undefined', async () => { + // Arrange + const file = createMockFile() + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + + rerender() + + // Assert - Should not crash, API should not be called again + expect(container.firstChild).toBeInTheDocument() + expect(mockFetchFilePreview).toHaveBeenCalledTimes(1) + }) + }) + + // -------------------------------------------------------------------------- + // getFileName Helper Tests + // -------------------------------------------------------------------------- + describe('getFileName Helper', () => { + it('should extract name without extension for simple filename', async () => { + // Arrange + const file = createMockFile({ name: 'document.pdf' }) + + // Act + renderFilePreview({ file }) + + // Assert + expect(screen.getByText('document')).toBeInTheDocument() + }) + + it('should handle filename with multiple dots', async () => { + // Arrange + const file = createMockFile({ name: 'file.name.with.dots.txt' }) + + // Act + renderFilePreview({ file }) + + // Assert - Should join all parts except last with comma + expect(screen.getByText('file,name,with,dots')).toBeInTheDocument() + }) + + it('should return empty for filename without dot', async () => { + // Arrange + const file = createMockFile({ name: 'nodotfile' }) + + // Act + const { container } = renderFilePreview({ file }) + + // Assert - slice(0, -1) on single element array returns empty + const fileNameElement = container.querySelector('.fileName') + const firstSpan = fileNameElement?.querySelector('span:first-child') + expect(firstSpan?.textContent).toBe('') + }) + + it('should return empty string when file is undefined', async () => { + // Arrange & Act + const { container } = renderFilePreview({ file: undefined }) + + // Assert - File name area should have empty first span + const fileNameElement = container.querySelector('.system-xs-medium') + expect(fileNameElement).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have clickable close button with visual indicator', async () => { + // Arrange & Act + const { container } = renderFilePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveClass('cursor-pointer') + }) + + it('should have proper heading structure', async () => { + // Arrange & Act + renderFilePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Error Handling Tests + // -------------------------------------------------------------------------- + describe('Error Handling', () => { + it('should not crash on API network error', async () => { + // Arrange + mockFetchFilePreview.mockRejectedValue(new Error('Network Error')) + + // Act + const { container } = renderFilePreview() + + // Assert - Component should still render + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on API timeout', async () => { + // Arrange + mockFetchFilePreview.mockRejectedValue(new Error('Timeout')) + + // Act + const { container } = renderFilePreview() + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on malformed API response', async () => { + // Arrange + mockFetchFilePreview.mockResolvedValue({} as { content: string }) + + // Act + const { container } = renderFilePreview() + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/index.spec.tsx new file mode 100644 index 0000000000..daec7a8cdf --- /dev/null +++ b/web/app/components/datasets/create/notion-page-preview/index.spec.tsx @@ -0,0 +1,1150 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import NotionPagePreview from './index' +import type { NotionPage } from '@/models/common' +import { fetchNotionPagePreview } from '@/service/datasets' + +// Mock the fetchNotionPagePreview service +jest.mock('@/service/datasets', () => ({ + fetchNotionPagePreview: jest.fn(), +})) + +const mockFetchNotionPagePreview = fetchNotionPagePreview as jest.MockedFunction + +// Factory function to create mock NotionPage objects +const createMockNotionPage = (overrides: Partial = {}): NotionPage => { + return { + page_id: 'page-123', + page_name: 'Test Page', + page_icon: null, + parent_id: 'parent-123', + type: 'page', + is_bound: false, + workspace_id: 'workspace-123', + ...overrides, + } +} + +// Factory function to create NotionPage with emoji icon +const createMockNotionPageWithEmojiIcon = (emoji: string, overrides: Partial = {}): NotionPage => { + return createMockNotionPage({ + page_icon: { + type: 'emoji', + url: null, + emoji, + }, + ...overrides, + }) +} + +// Factory function to create NotionPage with URL icon +const createMockNotionPageWithUrlIcon = (url: string, overrides: Partial = {}): NotionPage => { + return createMockNotionPage({ + page_icon: { + type: 'url', + url, + emoji: null, + }, + ...overrides, + }) +} + +// Helper to render NotionPagePreview with default props and wait for async updates +const renderNotionPagePreview = async ( + props: Partial<{ + currentPage?: NotionPage + notionCredentialId: string + hidePreview: () => void + }> = {}, + waitForContent = true, +) => { + const defaultProps = { + currentPage: createMockNotionPage(), + notionCredentialId: 'credential-123', + hidePreview: jest.fn(), + ...props, + } + const result = render() + + // Wait for async state updates to complete if needed + if (waitForContent && defaultProps.currentPage) { + await waitFor(() => { + // Wait for loading to finish + expect(result.container.querySelector('.spin-animation')).not.toBeInTheDocument() + }) + } + + return { + ...result, + props: defaultProps, + } +} + +// Helper to find the loading spinner element +const findLoadingSpinner = (container: HTMLElement) => { + return container.querySelector('.spin-animation') +} + +// ============================================================================ +// NotionPagePreview Component Tests +// ============================================================================ +// Note: Branch coverage is ~88% because line 29 (`if (!currentPage) return`) +// is defensive code that cannot be reached - getPreviewContent is only called +// from useEffect when currentPage is truthy. +// ============================================================================ +describe('NotionPagePreview', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default successful API response + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Preview content here' }) + }) + + afterEach(async () => { + // Wait for any pending state updates to complete + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', async () => { + // Arrange & Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should render page preview header', async () => { + // Arrange & Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should render close button with XMarkIcon', async () => { + // Arrange & Act + const { container } = await renderNotionPagePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + const xMarkIcon = closeButton?.querySelector('svg') + expect(xMarkIcon).toBeInTheDocument() + }) + + it('should render page name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: 'My Notion Page' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('My Notion Page')).toBeInTheDocument() + }) + + it('should apply correct CSS classes to container', async () => { + // Arrange & Act + const { container } = await renderNotionPagePreview() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('h-full') + }) + + it('should render NotionIcon component', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - NotionIcon should be rendered (either as img or div or svg) + const iconContainer = container.querySelector('.mr-1.shrink-0') + expect(iconContainer).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // NotionIcon Rendering Tests + // -------------------------------------------------------------------------- + describe('NotionIcon Rendering', () => { + it('should render default icon when page_icon is null', async () => { + // Arrange + const page = createMockNotionPage({ page_icon: null }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should render RiFileTextLine icon (svg) + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('should render emoji icon when page_icon has emoji type', async () => { + // Arrange + const page = createMockNotionPageWithEmojiIcon('📝') + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('📝')).toBeInTheDocument() + }) + + it('should render image icon when page_icon has url type', async () => { + // Arrange + const page = createMockNotionPageWithUrlIcon('https://example.com/icon.png') + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert + const img = container.querySelector('img[alt="page icon"]') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/icon.png') + }) + }) + + // -------------------------------------------------------------------------- + // Loading State Tests + // -------------------------------------------------------------------------- + describe('Loading State', () => { + it('should show loading indicator initially', async () => { + // Arrange - Delay API response to keep loading state + mockFetchNotionPagePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)), + ) + + // Act - Don't wait for content to load + const { container } = await renderNotionPagePreview({}, false) + + // Assert - Loading should be visible initially + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should hide loading indicator after content loads', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Loaded content' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + expect(screen.getByText('Loaded content')).toBeInTheDocument() + // Loading should be gone + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + + it('should show loading when currentPage changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' }) + const page2 = createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' }) + + let resolveFirst: (value: { content: string }) => void + let resolveSecond: (value: { content: string }) => void + + mockFetchNotionPagePreview + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act - Initial render + const { rerender, container } = render( + , + ) + + // First page loading - spinner should be visible + expect(findLoadingSpinner(container)).toBeInTheDocument() + + // Resolve first page + await act(async () => { + resolveFirst({ content: 'Content 1' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Rerender with new page + rerender() + + // Should show loading again + await waitFor(() => { + expect(findLoadingSpinner(container)).toBeInTheDocument() + }) + + // Resolve second page + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // API Call Tests + // -------------------------------------------------------------------------- + describe('API Calls', () => { + it('should call fetchNotionPagePreview with correct parameters', async () => { + // Arrange + const page = createMockNotionPage({ + page_id: 'test-page-id', + type: 'database', + }) + + // Act + await renderNotionPagePreview({ + currentPage: page, + notionCredentialId: 'test-credential-id', + }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ + pageID: 'test-page-id', + pageType: 'database', + credentialID: 'test-credential-id', + }) + }) + + it('should not call fetchNotionPagePreview when currentPage is undefined', async () => { + // Arrange & Act + await renderNotionPagePreview({ currentPage: undefined }, false) + + // Assert + expect(mockFetchNotionPagePreview).not.toHaveBeenCalled() + }) + + it('should call fetchNotionPagePreview again when currentPage changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ + pageID: 'page-1', + pageType: 'page', + credentialID: 'cred-123', + }) + }) + + await act(async () => { + rerender() + }) + + // Assert + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ + pageID: 'page-2', + pageType: 'page', + credentialID: 'cred-123', + }) + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should handle API success and display content', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('Notion page preview content from API')).toBeInTheDocument() + }) + + it('should handle API error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('Network error')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert - Component should not crash + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + // Header should still render + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should handle empty content response', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert - Should still render without loading + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions Tests + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = await renderNotionPagePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(1) + }) + + it('should handle multiple clicks on close button', async () => { + // Arrange + const hidePreview = jest.fn() + const { container } = await renderNotionPagePreview({ hidePreview }) + + // Act + const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + fireEvent.click(closeButton) + fireEvent.click(closeButton) + + // Assert + expect(hidePreview).toHaveBeenCalledTimes(3) + }) + }) + + // -------------------------------------------------------------------------- + // State Management Tests + // -------------------------------------------------------------------------- + describe('State Management', () => { + it('should initialize with loading state true', async () => { + // Arrange - Keep loading indefinitely (never resolves) + mockFetchNotionPagePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ })) + + // Act - Don't wait for content + const { container } = await renderNotionPagePreview({}, false) + + // Assert + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + + it('should update previewContent state after successful fetch', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'New preview content' }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('New preview content')).toBeInTheDocument() + }) + + it('should reset loading to true when currentPage changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + mockFetchNotionPagePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change page + await act(async () => { + rerender() + }) + + // Assert - Loading should be shown again + await waitFor(() => { + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).toBeInTheDocument() + }) + }) + + it('should replace old content with new content when page changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + let resolveSecond: (value: { content: string }) => void + + mockFetchNotionPagePreview + .mockResolvedValueOnce({ content: 'Content 1' }) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + // Change page + await act(async () => { + rerender() + }) + + // Resolve second fetch + await act(async () => { + resolveSecond({ content: 'Content 2' }) + }) + + await waitFor(() => { + expect(screen.getByText('Content 2')).toBeInTheDocument() + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('currentPage prop', () => { + it('should render correctly with currentPage prop', async () => { + // Arrange + const page = createMockNotionPage({ page_name: 'My Test Page' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('My Test Page')).toBeInTheDocument() + }) + + it('should render correctly without currentPage prop (undefined)', async () => { + // Arrange & Act + await renderNotionPagePreview({ currentPage: undefined }, false) + + // Assert - Header should still render + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + + it('should handle page with empty name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: '' }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle page with very long name', async () => { + // Arrange + const longName = 'a'.repeat(200) + const page = createMockNotionPage({ page_name: longName }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle page with special characters in name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: 'Page with & "chars"' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('Page with & "chars"')).toBeInTheDocument() + }) + + it('should handle page with unicode characters in name', async () => { + // Arrange + const page = createMockNotionPage({ page_name: '中文页面名称 🚀 日本語' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('中文页面名称 🚀 日本語')).toBeInTheDocument() + }) + }) + + describe('notionCredentialId prop', () => { + it('should pass notionCredentialId to API call', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + await renderNotionPagePreview({ + currentPage: page, + notionCredentialId: 'my-credential-id', + }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ credentialID: 'my-credential-id' }), + ) + }) + }) + + describe('hidePreview prop', () => { + it('should accept hidePreview callback', async () => { + // Arrange + const hidePreview = jest.fn() + + // Act + await renderNotionPagePreview({ hidePreview }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle page with undefined page_id', async () => { + // Arrange + const page = createMockNotionPage({ page_id: undefined as unknown as string }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert - API should still be called (with undefined pageID) + expect(mockFetchNotionPagePreview).toHaveBeenCalled() + }) + + it('should handle page with empty string page_id', async () => { + // Arrange + const page = createMockNotionPage({ page_id: '' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageID: '' }), + ) + }) + + it('should handle very long preview content', async () => { + // Arrange + const longContent = 'x'.repeat(10000) + mockFetchNotionPagePreview.mockResolvedValue({ content: longContent }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + + it('should handle preview content with special characters safely', async () => { + // Arrange + const specialContent = '\n\t& < > "' + mockFetchNotionPagePreview.mockResolvedValue({ content: specialContent }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert - Should render as text, not execute scripts + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toContain('alert') + }) + + it('should handle preview content with unicode', async () => { + // Arrange + const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs' + mockFetchNotionPagePreview.mockResolvedValue({ content: unicodeContent }) + + // Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText(unicodeContent)).toBeInTheDocument() + }) + + it('should handle preview content with newlines', async () => { + // Arrange + const multilineContent = 'Line 1\nLine 2\nLine 3' + mockFetchNotionPagePreview.mockResolvedValue({ content: multilineContent }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toContain('Line 1') + expect(contentDiv?.textContent).toContain('Line 2') + expect(contentDiv?.textContent).toContain('Line 3') + }) + + it('should handle null content from API', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: null as unknown as string }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle different page types', async () => { + // Arrange + const databasePage = createMockNotionPage({ type: 'database' }) + + // Act + await renderNotionPagePreview({ currentPage: databasePage }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'database' }), + ) + }) + }) + + // -------------------------------------------------------------------------- + // Side Effects and Cleanup Tests + // -------------------------------------------------------------------------- + describe('Side Effects and Cleanup', () => { + it('should trigger effect when currentPage prop changes', async () => { + // Arrange + const page1 = createMockNotionPage({ page_id: 'page-1' }) + const page2 = createMockNotionPage({ page_id: 'page-2' }) + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2) + }) + }) + + it('should not trigger effect when hidePreview changes', async () => { + // Arrange + const page = createMockNotionPage() + const hidePreview1 = jest.fn() + const hidePreview2 = jest.fn() + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert - Should not call API again (currentPage didn't change by reference) + // Note: Since currentPage is the same object, effect should not re-run + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + it('should not trigger effect when notionCredentialId changes', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert - Should not call API again (only currentPage is in dependency array) + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + it('should handle rapid page changes', async () => { + // Arrange + const pages = Array.from({ length: 5 }, (_, i) => + createMockNotionPage({ page_id: `page-${i}` }), + ) + + // Act + const { rerender } = render( + , + ) + + // Rapidly change pages + for (let i = 1; i < pages.length; i++) { + await act(async () => { + rerender() + }) + } + + // Assert - Should have called API for each page + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(5) + }) + }) + + it('should handle unmount during loading', async () => { + // Arrange + mockFetchNotionPagePreview.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), + ) + + // Act - Don't wait for content + const { unmount } = await renderNotionPagePreview({}, false) + + // Unmount before API resolves + unmount() + + // Assert - No errors should be thrown + expect(true).toBe(true) + }) + + it('should handle page changing from defined to undefined', async () => { + // Arrange + const page = createMockNotionPage() + + // Act + const { rerender, container } = render( + , + ) + + await waitFor(() => { + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + rerender() + }) + + // Assert - Should not crash, API should not be called again + expect(container.firstChild).toBeInTheDocument() + expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1) + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have clickable close button with visual indicator', async () => { + // Arrange & Act + const { container } = await renderNotionPagePreview() + + // Assert + const closeButton = container.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveClass('cursor-pointer') + }) + + it('should have proper heading structure', async () => { + // Arrange & Act + await renderNotionPagePreview() + + // Assert + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Error Handling Tests + // -------------------------------------------------------------------------- + describe('Error Handling', () => { + it('should not crash on API network error', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('Network Error')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert - Component should still render + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on API timeout', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('Timeout')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should not crash on malformed API response', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({} as { content: string }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle 404 error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('404 Not Found')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should handle 500 error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('500 Internal Server Error')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should handle authorization error gracefully', async () => { + // Arrange + mockFetchNotionPagePreview.mockRejectedValue(new Error('401 Unauthorized')) + + // Act + const { container } = await renderNotionPagePreview({}, false) + + // Assert + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Page Type Variations Tests + // -------------------------------------------------------------------------- + describe('Page Type Variations', () => { + it('should handle page type', async () => { + // Arrange + const page = createMockNotionPage({ type: 'page' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'page' }), + ) + }) + + it('should handle database type', async () => { + // Arrange + const page = createMockNotionPage({ type: 'database' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'database' }), + ) + }) + + it('should handle unknown type', async () => { + // Arrange + const page = createMockNotionPage({ type: 'unknown_type' }) + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( + expect.objectContaining({ pageType: 'unknown_type' }), + ) + }) + }) + + // -------------------------------------------------------------------------- + // Icon Type Variations Tests + // -------------------------------------------------------------------------- + describe('Icon Type Variations', () => { + it('should handle page with null icon', async () => { + // Arrange + const page = createMockNotionPage({ page_icon: null }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should render default icon + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('should handle page with emoji icon object', async () => { + // Arrange + const page = createMockNotionPageWithEmojiIcon('📄') + + // Act + await renderNotionPagePreview({ currentPage: page }) + + // Assert + expect(screen.getByText('📄')).toBeInTheDocument() + }) + + it('should handle page with url icon object', async () => { + // Arrange + const page = createMockNotionPageWithUrlIcon('https://example.com/custom-icon.png') + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert + const img = container.querySelector('img[alt="page icon"]') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png') + }) + + it('should handle page with icon object having null values', async () => { + // Arrange + const page = createMockNotionPage({ + page_icon: { + type: null, + url: null, + emoji: null, + }, + }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Should render, likely with default/fallback + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle page with icon object having empty url', async () => { + // Arrange + // Suppress console.error for this test as we're intentionally testing empty src edge case + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + + const page = createMockNotionPage({ + page_icon: { + type: 'url', + url: '', + emoji: null, + }, + }) + + // Act + const { container } = await renderNotionPagePreview({ currentPage: page }) + + // Assert - Component should not crash, may render img or fallback + expect(container.firstChild).toBeInTheDocument() + // NotionIcon renders img when type is 'url' + const img = container.querySelector('img[alt="page icon"]') + if (img) + expect(img).toBeInTheDocument() + + // Restore console.error + consoleErrorSpy.mockRestore() + }) + }) + + // -------------------------------------------------------------------------- + // Content Display Tests + // -------------------------------------------------------------------------- + describe('Content Display', () => { + it('should display content in fileContent div with correct class', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: 'Test content' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv).toHaveTextContent('Test content') + }) + + it('should preserve whitespace in content', async () => { + // Arrange + const contentWithWhitespace = ' indented content\n more indent' + mockFetchNotionPagePreview.mockResolvedValue({ content: contentWithWhitespace }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + // The CSS class has white-space: pre-line + expect(contentDiv?.textContent).toContain('indented content') + }) + + it('should display empty string content without loading', async () => { + // Arrange + mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) + + // Act + const { container } = await renderNotionPagePreview() + + // Assert + const loadingElement = findLoadingSpinner(container) + expect(loadingElement).not.toBeInTheDocument() + const contentDiv = container.querySelector('.fileContent') + expect(contentDiv).toBeInTheDocument() + expect(contentDiv?.textContent).toBe('') + }) + }) +}) diff --git a/web/app/components/datasets/create/step-three/index.spec.tsx b/web/app/components/datasets/create/step-three/index.spec.tsx new file mode 100644 index 0000000000..02746c8aee --- /dev/null +++ b/web/app/components/datasets/create/step-three/index.spec.tsx @@ -0,0 +1,844 @@ +import { render, screen } from '@testing-library/react' +import StepThree from './index' +import type { FullDocumentDetail, IconInfo, createDocumentResponse } from '@/models/datasets' + +// Mock the EmbeddingProcess component since it has complex async logic +jest.mock('../embedding-process', () => ({ + __esModule: true, + default: jest.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => ( +
+ {datasetId} + {batchId} + {documents?.length ?? 0} + {indexingType} + {retrievalMethod} +
+ )), +})) + +// Mock useBreakpoints hook +let mockMediaType = 'pc' +jest.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, + default: jest.fn(() => mockMediaType), +})) + +// Mock useDocLink hook +jest.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.dify.ai/en-US${path || ''}`, +})) + +// Factory function to create mock IconInfo +const createMockIconInfo = (overrides: Partial = {}): IconInfo => ({ + icon: '📙', + icon_type: 'emoji', + icon_background: '#FFF4ED', + icon_url: '', + ...overrides, +}) + +// Factory function to create mock FullDocumentDetail +const createMockDocument = (overrides: Partial = {}): FullDocumentDetail => ({ + id: 'doc-123', + name: 'test-document.txt', + data_source_type: 'upload_file', + data_source_info: { + upload_file: { + id: 'file-123', + name: 'test-document.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 1024, + created_by: 'user-1', + created_at: Date.now(), + }, + }, + batch: 'batch-123', + created_api_request_id: 'request-123', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + tokens: 100, + indexing_latency: 5000, + completed_at: Date.now(), + paused_by: '', + paused_at: 0, + stopped_at: 0, + indexing_status: 'completed', + disabled_at: 0, + ...overrides, +} as FullDocumentDetail) + +// Factory function to create mock createDocumentResponse +const createMockCreationCache = (overrides: Partial = {}): createDocumentResponse => ({ + dataset: { + id: 'dataset-123', + name: 'Test Dataset', + icon_info: createMockIconInfo(), + indexing_technique: 'high_quality', + retrieval_model_dict: { + search_method: 'semantic_search', + }, + } as createDocumentResponse['dataset'], + batch: 'batch-123', + documents: [createMockDocument()] as createDocumentResponse['documents'], + ...overrides, +}) + +// Helper to render StepThree with default props +const renderStepThree = (props: Partial[0]> = {}) => { + const defaultProps = { + ...props, + } + return render() +} + +// ============================================================================ +// StepThree Component Tests +// ============================================================================ +describe('StepThree', () => { + beforeEach(() => { + jest.clearAllMocks() + mockMediaType = 'pc' + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should render with creation title when datasetId is not provided', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument() + }) + + it('should render with addition title when datasetId is provided', () => { + // Arrange & Act + renderStepThree({ + datasetId: 'existing-dataset-123', + datasetName: 'Existing Dataset', + }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument() + }) + + it('should render label text in creation mode', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument() + }) + + it('should render side tip panel on desktop', () => { + // Arrange + mockMediaType = 'pc' + + // Act + renderStepThree() + + // Assert + expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() + }) + + it('should not render side tip panel on mobile', () => { + // Arrange + mockMediaType = 'mobile' + + // Act + renderStepThree() + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument() + }) + + it('should render EmbeddingProcess component', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should render documentation link with correct href on desktop', () => { + // Arrange + mockMediaType = 'pc' + + // Act + renderStepThree() + + // Assert + const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noreferrer noopener') + }) + + it('should apply correct container classes', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto') + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('datasetId prop', () => { + it('should render creation mode when datasetId is undefined', () => { + // Arrange & Act + renderStepThree({ datasetId: undefined }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + }) + + it('should render addition mode when datasetId is provided', () => { + // Arrange & Act + renderStepThree({ datasetId: 'dataset-123' }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() + }) + + it('should pass datasetId to EmbeddingProcess', () => { + // Arrange + const datasetId = 'my-dataset-id' + + // Act + renderStepThree({ datasetId }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId) + }) + + it('should use creationCache dataset id when datasetId is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123') + }) + }) + + describe('datasetName prop', () => { + it('should display datasetName in creation mode', () => { + // Arrange & Act + renderStepThree({ datasetName: 'My Custom Dataset' }) + + // Assert + expect(screen.getByText('My Custom Dataset')).toBeInTheDocument() + }) + + it('should display datasetName in addition mode description', () => { + // Arrange & Act + renderStepThree({ + datasetId: 'dataset-123', + datasetName: 'Existing Dataset Name', + }) + + // Assert - Check the text contains the dataset name (in the description) + const description = screen.getByText(/datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i) + expect(description).toBeInTheDocument() + }) + + it('should fallback to creationCache dataset name when datasetName is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.name = 'Cache Dataset Name' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument() + }) + }) + + describe('indexingType prop', () => { + it('should pass indexingType to EmbeddingProcess', () => { + // Arrange & Act + renderStepThree({ indexingType: 'high_quality' }) + + // Assert + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality') + }) + + it('should use creationCache indexing_technique when indexingType is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.indexing_technique = 'economy' as any + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') + }) + + it('should prefer creationCache indexing_technique over indexingType prop', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.indexing_technique = 'cache_technique' as any + + // Act + renderStepThree({ creationCache, indexingType: 'prop_technique' }) + + // Assert - creationCache takes precedence + expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('cache_technique') + }) + }) + + describe('retrievalMethod prop', () => { + it('should pass retrievalMethod to EmbeddingProcess', () => { + // Arrange & Act + renderStepThree({ retrievalMethod: 'semantic_search' }) + + // Assert + expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search') + }) + + it('should use creationCache retrieval method when retrievalMethod is not provided', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search') + }) + }) + + describe('creationCache prop', () => { + it('should pass batchId from creationCache to EmbeddingProcess', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.batch = 'custom-batch-123' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123') + }) + + it('should pass documents from creationCache to EmbeddingProcess', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') + }) + + it('should use icon_info from creationCache dataset', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.icon_info = createMockIconInfo({ + icon: '🚀', + icon_background: '#FF0000', + }) + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Check AppIcon component receives correct props + const appIcon = container.querySelector('span[style*="background"]') + expect(appIcon).toBeInTheDocument() + }) + + it('should handle undefined creationCache', () => { + // Arrange & Act + renderStepThree({ creationCache: undefined }) + + // Assert - Should not crash, use fallback values + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('') + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') + }) + + it('should handle creationCache with undefined dataset', () => { + // Arrange + const creationCache: createDocumentResponse = { + dataset: undefined, + batch: 'batch-123', + documents: [], + } + + // Act + renderStepThree({ creationCache }) + + // Assert - Should use default icon info + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests - Test null, undefined, empty values and boundaries + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle all props being undefined', () => { + // Arrange & Act + renderStepThree({ + datasetId: undefined, + datasetName: undefined, + indexingType: undefined, + retrievalMethod: undefined, + creationCache: undefined, + }) + + // Assert - Should render creation mode with fallbacks + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should handle empty string datasetId', () => { + // Arrange & Act + renderStepThree({ datasetId: '' }) + + // Assert - Empty string is falsy, should show creation mode + expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() + }) + + it('should handle empty string datasetName', () => { + // Arrange & Act + renderStepThree({ datasetName: '' }) + + // Assert - Should not crash + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should handle empty documents array in creationCache', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.documents = [] + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') + }) + + it('should handle creationCache with missing icon_info', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.icon_info = undefined as any + + // Act + renderStepThree({ creationCache }) + + // Assert - Should use default icon info + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + + it('should handle very long datasetName', () => { + // Arrange + const longName = 'A'.repeat(500) + + // Act + renderStepThree({ datasetName: longName }) + + // Assert - Should render without crashing + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in datasetName', () => { + // Arrange + const specialName = 'Dataset & "quotes" \'apostrophe\'' + + // Act + renderStepThree({ datasetName: specialName }) + + // Assert - Should render safely as text + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle unicode characters in datasetName', () => { + // Arrange + const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs' + + // Act + renderStepThree({ datasetName: unicodeName }) + + // Assert + expect(screen.getByText(unicodeName)).toBeInTheDocument() + }) + + it('should handle creationCache with null dataset name', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.name = null as any + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Should not crash + expect(container.firstChild).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Conditional Rendering Tests - Test mode switching behavior + // -------------------------------------------------------------------------- + describe('Conditional Rendering', () => { + describe('Creation Mode (no datasetId)', () => { + it('should show AppIcon component', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert - AppIcon should be rendered + const appIcon = container.querySelector('span') + expect(appIcon).toBeInTheDocument() + }) + + it('should show Divider component', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert - Divider should be rendered (it adds hr with specific classes) + const dividers = container.querySelectorAll('[class*="divider"]') + expect(dividers.length).toBeGreaterThan(0) + }) + + it('should show dataset name input area', () => { + // Arrange + const datasetName = 'Test Dataset Name' + + // Act + renderStepThree({ datasetName }) + + // Assert + expect(screen.getByText(datasetName)).toBeInTheDocument() + }) + }) + + describe('Addition Mode (with datasetId)', () => { + it('should not show AppIcon component', () => { + // Arrange & Act + renderStepThree({ datasetId: 'dataset-123' }) + + // Assert - Creation section should not be rendered + expect(screen.queryByText('datasetCreation.stepThree.label')).not.toBeInTheDocument() + }) + + it('should show addition description with dataset name', () => { + // Arrange & Act + renderStepThree({ + datasetId: 'dataset-123', + datasetName: 'My Dataset', + }) + + // Assert - Description should include dataset name + expect(screen.getByText(/datasetCreation.stepThree.additionP1/)).toBeInTheDocument() + }) + }) + + describe('Mobile vs Desktop', () => { + it('should show side panel on tablet', () => { + // Arrange + mockMediaType = 'tablet' + + // Act + renderStepThree() + + // Assert - Tablet is not mobile, should show side panel + expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() + }) + + it('should not show side panel on mobile', () => { + // Arrange + mockMediaType = 'mobile' + + // Act + renderStepThree() + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() + }) + + it('should render EmbeddingProcess on mobile', () => { + // Arrange + mockMediaType = 'mobile' + + // Act + renderStepThree() + + // Assert - Main content should still be rendered + expect(screen.getByTestId('embedding-process')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // EmbeddingProcess Integration Tests - Verify correct props are passed + // -------------------------------------------------------------------------- + describe('EmbeddingProcess Integration', () => { + it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => { + // Arrange & Act + renderStepThree({ datasetId: 'direct-dataset-id' }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id') + }) + + it('should pass creationCache dataset id when datasetId prop is undefined', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.id = 'cache-dataset-id' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id') + }) + + it('should pass empty string for datasetId when both sources are undefined', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('') + }) + + it('should pass batchId from creationCache', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.batch = 'test-batch-456' + + // Act + renderStepThree({ creationCache }) + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456') + }) + + it('should pass empty string for batchId when creationCache is undefined', () => { + // Arrange & Act + renderStepThree() + + // Assert + expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') + }) + + it('should prefer datasetId prop over creationCache dataset id', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.id = 'cache-id' + + // Act + renderStepThree({ datasetId: 'prop-id', creationCache }) + + // Assert - datasetId prop takes precedence + expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('prop-id') + }) + }) + + // -------------------------------------------------------------------------- + // Icon Rendering Tests - Verify AppIcon behavior + // -------------------------------------------------------------------------- + describe('Icon Rendering', () => { + it('should use default icon info when creationCache is undefined', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert - Default background color should be applied + const appIcon = container.querySelector('span[style*="background"]') + if (appIcon) + expect(appIcon).toHaveStyle({ background: '#FFF4ED' }) + }) + + it('should use icon_info from creationCache when available', () => { + // Arrange + const creationCache = createMockCreationCache() + creationCache.dataset!.icon_info = { + icon: '🎉', + icon_type: 'emoji', + icon_background: '#00FF00', + icon_url: '', + } + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Custom background color should be applied + const appIcon = container.querySelector('span[style*="background"]') + if (appIcon) + expect(appIcon).toHaveStyle({ background: '#00FF00' }) + }) + + it('should use default icon when creationCache dataset icon_info is undefined', () => { + // Arrange + const creationCache = createMockCreationCache() + delete (creationCache.dataset as any).icon_info + + // Act + const { container } = renderStepThree({ creationCache }) + + // Assert - Component should still render with default icon + expect(container.firstChild).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Layout Tests - Verify correct CSS classes and structure + // -------------------------------------------------------------------------- + describe('Layout', () => { + it('should have correct outer container classes', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex') + expect(outerDiv).toHaveClass('h-full') + expect(outerDiv).toHaveClass('justify-center') + }) + + it('should have correct inner container classes', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const innerDiv = container.querySelector('.max-w-\\[960px\\]') + expect(innerDiv).toBeInTheDocument() + expect(innerDiv).toHaveClass('shrink-0', 'grow') + }) + + it('should have content wrapper with correct max width', () => { + // Arrange & Act + const { container } = renderStepThree() + + // Assert + const contentWrapper = container.querySelector('.max-w-\\[640px\\]') + expect(contentWrapper).toBeInTheDocument() + }) + + it('should have side tip panel with correct width on desktop', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert + const sidePanel = container.querySelector('.w-\\[328px\\]') + expect(sidePanel).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests - Verify accessibility features + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have correct link attributes for external documentation link', () => { + // Arrange + mockMediaType = 'pc' + + // Act + renderStepThree() + + // Assert + const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') + expect(link.tagName).toBe('A') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noreferrer noopener') + }) + + it('should have semantic heading structure in creation mode', () => { + // Arrange & Act + renderStepThree() + + // Assert + const title = screen.getByText('datasetCreation.stepThree.creationTitle') + expect(title).toBeInTheDocument() + expect(title.className).toContain('title-2xl-semi-bold') + }) + + it('should have semantic heading structure in addition mode', () => { + // Arrange & Act + renderStepThree({ datasetId: 'dataset-123' }) + + // Assert + const title = screen.getByText('datasetCreation.stepThree.additionTitle') + expect(title).toBeInTheDocument() + expect(title.className).toContain('title-2xl-semi-bold') + }) + }) + + // -------------------------------------------------------------------------- + // Side Panel Tests - Verify side panel behavior + // -------------------------------------------------------------------------- + describe('Side Panel', () => { + it('should render RiBookOpenLine icon in side panel', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert - Icon should be present in side panel + const iconContainer = container.querySelector('.size-10') + expect(iconContainer).toBeInTheDocument() + }) + + it('should have correct side panel section background', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert + const sidePanel = container.querySelector('.bg-background-section') + expect(sidePanel).toBeInTheDocument() + }) + + it('should have correct padding for side panel', () => { + // Arrange + mockMediaType = 'pc' + + // Act + const { container } = renderStepThree() + + // Assert + const sidePanelWrapper = container.querySelector('.pr-8') + expect(sidePanelWrapper).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/stepper/index.spec.tsx b/web/app/components/datasets/create/stepper/index.spec.tsx new file mode 100644 index 0000000000..174c2d3472 --- /dev/null +++ b/web/app/components/datasets/create/stepper/index.spec.tsx @@ -0,0 +1,735 @@ +import { render, screen } from '@testing-library/react' +import { Stepper, type StepperProps } from './index' +import { type Step, StepperStep, type StepperStepProps } from './step' + +// Test data factory for creating steps +const createStep = (overrides: Partial = {}): Step => ({ + name: 'Test Step', + ...overrides, +}) + +const createSteps = (count: number, namePrefix = 'Step'): Step[] => + Array.from({ length: count }, (_, i) => createStep({ name: `${namePrefix} ${i + 1}` })) + +// Helper to render Stepper with default props +const renderStepper = (props: Partial = {}) => { + const defaultProps: StepperProps = { + steps: createSteps(3), + activeIndex: 0, + ...props, + } + return render() +} + +// Helper to render StepperStep with default props +const renderStepperStep = (props: Partial = {}) => { + const defaultProps: StepperStepProps = { + name: 'Test Step', + index: 0, + activeIndex: 0, + ...props, + } + return render() +} + +// ============================================================================ +// Stepper Component Tests +// ============================================================================ +describe('Stepper', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly with various inputs + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderStepper() + + // Assert + expect(screen.getByText('Step 1')).toBeInTheDocument() + }) + + it('should render all step names', () => { + // Arrange + const steps = createSteps(3, 'Custom Step') + + // Act + renderStepper({ steps }) + + // Assert + expect(screen.getByText('Custom Step 1')).toBeInTheDocument() + expect(screen.getByText('Custom Step 2')).toBeInTheDocument() + expect(screen.getByText('Custom Step 3')).toBeInTheDocument() + }) + + it('should render dividers between steps', () => { + // Arrange + const steps = createSteps(3) + + // Act + const { container } = renderStepper({ steps }) + + // Assert - Should have 2 dividers for 3 steps + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(2) + }) + + it('should not render divider after last step', () => { + // Arrange + const steps = createSteps(2) + + // Act + const { container } = renderStepper({ steps }) + + // Assert - Should have 1 divider for 2 steps + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(1) + }) + + it('should render with flex container layout', () => { + // Arrange & Act + const { container } = renderStepper() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3') + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations and combinations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('steps prop', () => { + it('should render correct number of steps', () => { + // Arrange + const steps = createSteps(5) + + // Act + renderStepper({ steps }) + + // Assert + expect(screen.getByText('Step 1')).toBeInTheDocument() + expect(screen.getByText('Step 2')).toBeInTheDocument() + expect(screen.getByText('Step 3')).toBeInTheDocument() + expect(screen.getByText('Step 4')).toBeInTheDocument() + expect(screen.getByText('Step 5')).toBeInTheDocument() + }) + + it('should handle single step correctly', () => { + // Arrange + const steps = [createStep({ name: 'Only Step' })] + + // Act + const { container } = renderStepper({ steps, activeIndex: 0 }) + + // Assert + expect(screen.getByText('Only Step')).toBeInTheDocument() + // No dividers for single step + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(0) + }) + + it('should handle steps with long names', () => { + // Arrange + const longName = 'This is a very long step name that might overflow' + const steps = [createStep({ name: longName })] + + // Act + renderStepper({ steps, activeIndex: 0 }) + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle steps with special characters', () => { + // Arrange + const steps = [ + createStep({ name: 'Step & Configuration' }), + createStep({ name: 'Step ' }), + createStep({ name: 'Step "Complete"' }), + ] + + // Act + renderStepper({ steps, activeIndex: 0 }) + + // Assert + expect(screen.getByText('Step & Configuration')).toBeInTheDocument() + expect(screen.getByText('Step ')).toBeInTheDocument() + expect(screen.getByText('Step "Complete"')).toBeInTheDocument() + }) + }) + + describe('activeIndex prop', () => { + it('should highlight first step when activeIndex is 0', () => { + // Arrange & Act + renderStepper({ activeIndex: 0 }) + + // Assert - First step should show "STEP 1" label + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should highlight second step when activeIndex is 1', () => { + // Arrange & Act + renderStepper({ activeIndex: 1 }) + + // Assert - Second step should show "STEP 2" label + expect(screen.getByText('STEP 2')).toBeInTheDocument() + }) + + it('should highlight last step when activeIndex equals steps length - 1', () => { + // Arrange + const steps = createSteps(3) + + // Act + renderStepper({ steps, activeIndex: 2 }) + + // Assert - Third step should show "STEP 3" label + expect(screen.getByText('STEP 3')).toBeInTheDocument() + }) + + it('should show completed steps with number only (no STEP prefix)', () => { + // Arrange + const steps = createSteps(3) + + // Act + renderStepper({ steps, activeIndex: 2 }) + + // Assert - Completed steps show just the number + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('STEP 3')).toBeInTheDocument() + }) + + it('should show disabled steps with number only (no STEP prefix)', () => { + // Arrange + const steps = createSteps(3) + + // Act + renderStepper({ steps, activeIndex: 0 }) + + // Assert - Disabled steps show just the number + expect(screen.getByText('STEP 1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases - Test boundary conditions and unexpected inputs + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle empty steps array', () => { + // Arrange & Act + const { container } = renderStepper({ steps: [] }) + + // Assert - Container should render but be empty + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should handle activeIndex greater than steps length', () => { + // Arrange + const steps = createSteps(2) + + // Act - activeIndex 5 is beyond array bounds + renderStepper({ steps, activeIndex: 5 }) + + // Assert - All steps should render as completed (since activeIndex > all indices) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should handle negative activeIndex', () => { + // Arrange + const steps = createSteps(2) + + // Act - negative activeIndex + renderStepper({ steps, activeIndex: -1 }) + + // Assert - All steps should render as disabled (since activeIndex < all indices) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should handle large number of steps', () => { + // Arrange + const steps = createSteps(10) + + // Act + const { container } = renderStepper({ steps, activeIndex: 5 }) + + // Assert + expect(screen.getByText('STEP 6')).toBeInTheDocument() + // Should have 9 dividers for 10 steps + const dividers = container.querySelectorAll('.bg-divider-deep') + expect(dividers.length).toBe(9) + }) + + it('should handle steps with empty name', () => { + // Arrange + const steps = [createStep({ name: '' })] + + // Act + const { container } = renderStepper({ steps, activeIndex: 0 }) + + // Assert - Should still render the step structure + expect(screen.getByText('STEP 1')).toBeInTheDocument() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Integration - Test step state combinations + // -------------------------------------------------------------------------- + describe('Step States', () => { + it('should render mixed states: completed, active, disabled', () => { + // Arrange + const steps = createSteps(5) + + // Act + renderStepper({ steps, activeIndex: 2 }) + + // Assert + // Steps 1-2 are completed (show number only) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + // Step 3 is active (shows STEP prefix) + expect(screen.getByText('STEP 3')).toBeInTheDocument() + // Steps 4-5 are disabled (show number only) + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should transition through all states correctly', () => { + // Arrange + const steps = createSteps(3) + + // Act & Assert - Step 1 active + const { rerender } = render() + expect(screen.getByText('STEP 1')).toBeInTheDocument() + + // Step 2 active + rerender() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('STEP 2')).toBeInTheDocument() + + // Step 3 active + rerender() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('STEP 3')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// StepperStep Component Tests +// ============================================================================ +describe('StepperStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderStepperStep() + + // Assert + expect(screen.getByText('Test Step')).toBeInTheDocument() + }) + + it('should render step name', () => { + // Arrange & Act + renderStepperStep({ name: 'Configure Dataset' }) + + // Assert + expect(screen.getByText('Configure Dataset')).toBeInTheDocument() + }) + + it('should render with flex container layout', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2') + }) + }) + + // -------------------------------------------------------------------------- + // Active State Tests + // -------------------------------------------------------------------------- + describe('Active State', () => { + it('should show STEP prefix when active', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should apply active styles to label container', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + const labelContainer = container.querySelector('.bg-state-accent-solid') + expect(labelContainer).toBeInTheDocument() + expect(labelContainer).toHaveClass('px-2') + }) + + it('should apply active text color to label', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + const label = container.querySelector('.text-text-primary-on-surface') + expect(label).toBeInTheDocument() + }) + + it('should apply accent text color to name when active', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + const nameElement = container.querySelector('.text-text-accent') + expect(nameElement).toBeInTheDocument() + expect(nameElement).toHaveClass('system-xs-semibold-uppercase') + }) + + it('should calculate active correctly for different indices', () => { + // Test index 1 with activeIndex 1 + const { rerender } = render( + , + ) + expect(screen.getByText('STEP 2')).toBeInTheDocument() + + // Test index 5 with activeIndex 5 + rerender() + expect(screen.getByText('STEP 6')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Completed State Tests (index < activeIndex) + // -------------------------------------------------------------------------- + describe('Completed State', () => { + it('should show number only when completed (not active)', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 1 }) + + // Assert + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.queryByText('STEP 1')).not.toBeInTheDocument() + }) + + it('should apply completed styles to label container', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) + + // Assert + const labelContainer = container.querySelector('.border-text-quaternary') + expect(labelContainer).toBeInTheDocument() + expect(labelContainer).toHaveClass('w-5') + }) + + it('should apply tertiary text color to label when completed', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) + + // Assert + const label = container.querySelector('.text-text-tertiary') + expect(label).toBeInTheDocument() + }) + + it('should apply tertiary text color to name when completed', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 0, activeIndex: 2 }) + + // Assert + const nameElements = container.querySelectorAll('.text-text-tertiary') + expect(nameElements.length).toBeGreaterThan(0) + }) + }) + + // -------------------------------------------------------------------------- + // Disabled State Tests (index > activeIndex) + // -------------------------------------------------------------------------- + describe('Disabled State', () => { + it('should show number only when disabled', () => { + // Arrange & Act + renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.queryByText('STEP 3')).not.toBeInTheDocument() + }) + + it('should apply disabled styles to label container', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + const labelContainer = container.querySelector('.border-divider-deep') + expect(labelContainer).toBeInTheDocument() + expect(labelContainer).toHaveClass('w-5') + }) + + it('should apply quaternary text color to label when disabled', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + const label = container.querySelector('.text-text-quaternary') + expect(label).toBeInTheDocument() + }) + + it('should apply quaternary text color to name when disabled', () => { + // Arrange & Act + const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) + + // Assert + const nameElements = container.querySelectorAll('.text-text-quaternary') + expect(nameElements.length).toBeGreaterThan(0) + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('name prop', () => { + it('should render provided name', () => { + // Arrange & Act + renderStepperStep({ name: 'Custom Name' }) + + // Assert + expect(screen.getByText('Custom Name')).toBeInTheDocument() + }) + + it('should handle empty name', () => { + // Arrange & Act + const { container } = renderStepperStep({ name: '' }) + + // Assert - Label should still render + expect(screen.getByText('STEP 1')).toBeInTheDocument() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle name with whitespace', () => { + // Arrange & Act + renderStepperStep({ name: ' Padded Name ' }) + + // Assert + expect(screen.getByText('Padded Name')).toBeInTheDocument() + }) + }) + + describe('index prop', () => { + it('should display correct 1-based number for index 0', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should display correct 1-based number for index 9', () => { + // Arrange & Act + renderStepperStep({ index: 9, activeIndex: 9 }) + + // Assert + expect(screen.getByText('STEP 10')).toBeInTheDocument() + }) + + it('should handle large index values', () => { + // Arrange & Act + renderStepperStep({ index: 99, activeIndex: 99 }) + + // Assert + expect(screen.getByText('STEP 100')).toBeInTheDocument() + }) + }) + + describe('activeIndex prop', () => { + it('should determine state based on activeIndex comparison', () => { + // Active: index === activeIndex + const { rerender } = render( + , + ) + expect(screen.getByText('STEP 2')).toBeInTheDocument() + + // Completed: index < activeIndex + rerender() + expect(screen.getByText('2')).toBeInTheDocument() + + // Disabled: index > activeIndex + rerender() + expect(screen.getByText('2')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle zero index correctly', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: 0 }) + + // Assert + expect(screen.getByText('STEP 1')).toBeInTheDocument() + }) + + it('should handle negative activeIndex', () => { + // Arrange & Act + renderStepperStep({ index: 0, activeIndex: -1 }) + + // Assert - Step should be disabled (index > activeIndex) + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should handle equal boundary (index equals activeIndex)', () => { + // Arrange & Act + renderStepperStep({ index: 5, activeIndex: 5 }) + + // Assert - Should be active + expect(screen.getByText('STEP 6')).toBeInTheDocument() + }) + + it('should handle name with HTML-like content safely', () => { + // Arrange & Act + renderStepperStep({ name: '' }) + + // Assert - Should render as text, not execute + expect(screen.getByText('')).toBeInTheDocument() + }) + + it('should handle name with unicode characters', () => { + // Arrange & Act + renderStepperStep({ name: 'Step 数据 🚀' }) + + // Assert + expect(screen.getByText('Step 数据 🚀')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Style Classes Verification + // -------------------------------------------------------------------------- + describe('Style Classes', () => { + it('should apply correct typography classes to label', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const label = container.querySelector('.system-2xs-semibold-uppercase') + expect(label).toBeInTheDocument() + }) + + it('should apply correct typography classes to name', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const name = container.querySelector('.system-xs-medium-uppercase') + expect(name).toBeInTheDocument() + }) + + it('should have rounded pill shape for label container', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const labelContainer = container.querySelector('.rounded-3xl') + expect(labelContainer).toBeInTheDocument() + }) + + it('should apply h-5 height to label container', () => { + // Arrange & Act + const { container } = renderStepperStep() + + // Assert + const labelContainer = container.querySelector('.h-5') + expect(labelContainer).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Integration Tests - Stepper and StepperStep working together +// ============================================================================ +describe('Stepper Integration', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should pass correct props to each StepperStep', () => { + // Arrange + const steps = [ + createStep({ name: 'First' }), + createStep({ name: 'Second' }), + createStep({ name: 'Third' }), + ] + + // Act + renderStepper({ steps, activeIndex: 1 }) + + // Assert - Each step receives correct index and displays correctly + expect(screen.getByText('1')).toBeInTheDocument() // Completed + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('STEP 2')).toBeInTheDocument() // Active + expect(screen.getByText('Second')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() // Disabled + expect(screen.getByText('Third')).toBeInTheDocument() + }) + + it('should maintain correct visual hierarchy across steps', () => { + // Arrange + const steps = createSteps(4) + + // Act + const { container } = renderStepper({ steps, activeIndex: 2 }) + + // Assert - Check visual hierarchy + // Completed steps (0, 1) have border-text-quaternary + const completedLabels = container.querySelectorAll('.border-text-quaternary') + expect(completedLabels.length).toBe(2) + + // Active step has bg-state-accent-solid + const activeLabel = container.querySelector('.bg-state-accent-solid') + expect(activeLabel).toBeInTheDocument() + + // Disabled step (3) has border-divider-deep + const disabledLabels = container.querySelectorAll('.border-divider-deep') + expect(disabledLabels.length).toBe(1) + }) + + it('should render correctly with dynamic step updates', () => { + // Arrange + const initialSteps = createSteps(2) + + // Act + const { rerender } = render() + expect(screen.getByText('Step 1')).toBeInTheDocument() + expect(screen.getByText('Step 2')).toBeInTheDocument() + + // Update with more steps + const updatedSteps = createSteps(4) + rerender() + + // Assert + expect(screen.getByText('STEP 3')).toBeInTheDocument() + expect(screen.getByText('Step 4')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx new file mode 100644 index 0000000000..244f65ffb0 --- /dev/null +++ b/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx @@ -0,0 +1,738 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import StopEmbeddingModal from './index' + +// Helper type for component props +type StopEmbeddingModalProps = { + show: boolean + onConfirm: () => void + onHide: () => void +} + +// Helper to render StopEmbeddingModal with default props +const renderStopEmbeddingModal = (props: Partial = {}) => { + const defaultProps: StopEmbeddingModalProps = { + show: true, + onConfirm: jest.fn(), + onHide: jest.fn(), + ...props, + } + return { + ...render(), + props: defaultProps, + } +} + +// ============================================================================ +// StopEmbeddingModal Component Tests +// ============================================================================ +describe('StopEmbeddingModal', () => { + // Suppress Headless UI warnings in tests + // These warnings are from the library's internal behavior, not our code + let consoleWarnSpy: jest.SpyInstance + let consoleErrorSpy: jest.SpyInstance + + beforeAll(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(jest.fn()) + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + }) + + afterAll(() => { + consoleWarnSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing when show is true', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + + it('should render modal title', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + + it('should render modal content', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + }) + + it('should render confirm button with correct text', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument() + }) + + it('should render cancel button with correct text', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument() + }) + + it('should not render modal content when show is false', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: false }) + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }) + + it('should render buttons in correct order (cancel first, then confirm)', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + + it('should render confirm button with primary variant styling', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + expect(confirmButton).toHaveClass('ml-2', 'w-24') + }) + + it('should render cancel button with default styling', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + expect(cancelButton).toHaveClass('w-24') + }) + + it('should render all modal elements', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert - Modal should contain title, content, and buttons + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('show prop', () => { + it('should show modal when show is true', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + + it('should hide modal when show is false', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: false }) + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }) + + it('should use default value false when show is not provided', () => { + // Arrange & Act + const onConfirm = jest.fn() + const onHide = jest.fn() + render() + + // Assert + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }) + + it('should toggle visibility when show prop changes to true', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + + // Act - Initially hidden + const { rerender } = render( + , + ) + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + + // Act - Show modal + await act(async () => { + rerender() + }) + + // Assert - Modal should be visible + await waitFor(() => { + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) + }) + + describe('onConfirm prop', () => { + it('should accept onConfirm callback function', () => { + // Arrange + const onConfirm = jest.fn() + + // Act + renderStopEmbeddingModal({ onConfirm }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) + + describe('onHide prop', () => { + it('should accept onHide callback function', () => { + // Arrange + const onHide = jest.fn() + + // Act + renderStopEmbeddingModal({ onHide }) + + // Assert - No errors thrown + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions Tests - Test click events and event handlers + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + describe('Confirm Button', () => { + it('should call onConfirm when confirm button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('should call onHide when confirm button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => { + // Arrange + const callOrder: string[] = [] + const onConfirm = jest.fn(() => callOrder.push('confirm')) + const onHide = jest.fn(() => callOrder.push('hide')) + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert - onConfirm should be called before onHide + expect(callOrder).toEqual(['confirm', 'hide']) + }) + + it('should handle multiple clicks on confirm button', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(3) + expect(onHide).toHaveBeenCalledTimes(3) + }) + }) + + describe('Cancel Button', () => { + it('should call onHide when cancel button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should not call onConfirm when cancel button is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + }) + + // Assert + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should handle multiple clicks on cancel button', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + fireEvent.click(cancelButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(2) + expect(onConfirm).not.toHaveBeenCalled() + }) + }) + + describe('Close Icon', () => { + it('should call onHide when close span is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Find the close span (it should be the span with onClick handler) + const spans = container.querySelectorAll('span') + const closeSpan = Array.from(spans).find(span => + span.className && span.getAttribute('class')?.includes('close'), + ) + + if (closeSpan) { + await act(async () => { + fireEvent.click(closeSpan) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + } + else { + // If no close span found with class, just verify the modal renders + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + } + }) + + it('should not call onConfirm when close span is clicked', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const spans = container.querySelectorAll('span') + const closeSpan = Array.from(spans).find(span => + span.className && span.getAttribute('class')?.includes('close'), + ) + + if (closeSpan) { + await act(async () => { + fireEvent.click(closeSpan) + }) + + // Assert + expect(onConfirm).not.toHaveBeenCalled() + } + }) + }) + + describe('Different Close Methods', () => { + it('should distinguish between confirm and cancel actions', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Click cancel + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + fireEvent.click(cancelButton) + }) + + // Assert + expect(onConfirm).not.toHaveBeenCalled() + expect(onHide).toHaveBeenCalledTimes(1) + + // Reset + jest.clearAllMocks() + + // Act - Click confirm + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onHide).toHaveBeenCalledTimes(1) + }) + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases Tests - Test null, undefined, empty values and boundaries + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle rapid confirm button clicks', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Rapid clicks + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + for (let i = 0; i < 10; i++) + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(10) + expect(onHide).toHaveBeenCalledTimes(10) + }) + + it('should handle rapid cancel button clicks', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act - Rapid clicks + const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') + await act(async () => { + for (let i = 0; i < 10; i++) + fireEvent.click(cancelButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledTimes(10) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should handle callbacks being replaced', async () => { + // Arrange + const onConfirm1 = jest.fn() + const onHide1 = jest.fn() + const onConfirm2 = jest.fn() + const onHide2 = jest.fn() + + // Act + const { rerender } = render( + , + ) + + // Replace callbacks + await act(async () => { + rerender() + }) + + // Click confirm with new callbacks + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert - New callbacks should be called + expect(onConfirm1).not.toHaveBeenCalled() + expect(onHide1).not.toHaveBeenCalled() + expect(onConfirm2).toHaveBeenCalledTimes(1) + expect(onHide2).toHaveBeenCalledTimes(1) + }) + + it('should render with all required props', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Layout and Styling Tests - Verify correct structure + // -------------------------------------------------------------------------- + describe('Layout and Styling', () => { + it('should have buttons container with flex-row-reverse', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse') + }) + + it('should render title and content elements', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() + }) + + it('should render two buttons', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + }) + + // -------------------------------------------------------------------------- + // submit Function Tests - Test the internal submit function behavior + // -------------------------------------------------------------------------- + describe('submit Function', () => { + it('should execute onConfirm first then onHide', async () => { + // Arrange + let confirmTime = 0 + let hideTime = 0 + let counter = 0 + const onConfirm = jest.fn(() => { + confirmTime = ++counter + }) + const onHide = jest.fn(() => { + hideTime = ++counter + }) + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(confirmTime).toBe(1) + expect(hideTime).toBe(2) + }) + + it('should call both callbacks exactly once per click', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should pass no arguments to onConfirm', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onConfirm).toHaveBeenCalledWith() + }) + + it('should pass no arguments to onHide when called from submit', async () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') + await act(async () => { + fireEvent.click(confirmButton) + }) + + // Assert + expect(onHide).toHaveBeenCalledWith() + }) + }) + + // -------------------------------------------------------------------------- + // Modal Integration Tests - Verify Modal component integration + // -------------------------------------------------------------------------- + describe('Modal Integration', () => { + it('should pass show prop to Modal as isShow', async () => { + // Arrange & Act + const { rerender } = render( + , + ) + + // Assert - Modal should be visible + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + + // Act - Hide modal + await act(async () => { + rerender() + }) + + // Assert - Modal should transition to hidden (wait for transition) + await waitFor(() => { + expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() + }, { timeout: 3000 }) + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have buttons that are focusable', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button).not.toHaveAttribute('tabindex', '-1') + }) + }) + + it('should have semantic button elements', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + + it('should have accessible text content', () => { + // Arrange & Act + renderStopEmbeddingModal({ show: true }) + + // Assert + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible() + expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible() + expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible() + expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeVisible() + }) + }) + + // -------------------------------------------------------------------------- + // Component Lifecycle Tests + // -------------------------------------------------------------------------- + describe('Component Lifecycle', () => { + it('should unmount cleanly', () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act & Assert - Should not throw + expect(() => unmount()).not.toThrow() + }) + + it('should not call callbacks after unmount', () => { + // Arrange + const onConfirm = jest.fn() + const onHide = jest.fn() + const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) + + // Act + unmount() + + // Assert - No callbacks should be called after unmount + expect(onConfirm).not.toHaveBeenCalled() + expect(onHide).not.toHaveBeenCalled() + }) + + it('should re-render correctly when props update', async () => { + // Arrange + const onConfirm1 = jest.fn() + const onHide1 = jest.fn() + const onConfirm2 = jest.fn() + const onHide2 = jest.fn() + + // Act - Initial render + const { rerender } = render( + , + ) + + // Verify initial render + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + + // Update props + await act(async () => { + rerender() + }) + + // Assert - Still renders correctly + expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/top-bar/index.spec.tsx b/web/app/components/datasets/create/top-bar/index.spec.tsx new file mode 100644 index 0000000000..92fb97c839 --- /dev/null +++ b/web/app/components/datasets/create/top-bar/index.spec.tsx @@ -0,0 +1,539 @@ +import { render, screen } from '@testing-library/react' +import { TopBar, type TopBarProps } from './index' + +// Mock next/link to capture href values +jest.mock('next/link', () => { + return ({ children, href, replace, className }: { children: React.ReactNode; href: string; replace?: boolean; className?: string }) => ( + + {children} + + ) +}) + +// Helper to render TopBar with default props +const renderTopBar = (props: Partial = {}) => { + const defaultProps: TopBarProps = { + activeIndex: 0, + ...props, + } + return { + ...render(), + props: defaultProps, + } +} + +// ============================================================================ +// TopBar Component Tests +// ============================================================================ +describe('TopBar', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - Verify component renders properly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderTopBar() + + // Assert + expect(screen.getByTestId('back-link')).toBeInTheDocument() + }) + + it('should render back link with arrow icon', () => { + // Arrange & Act + const { container } = renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toBeInTheDocument() + // Check for the arrow icon (svg element) + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + }) + + it('should render fallback route text', () => { + // Arrange & Act + renderTopBar() + + // Assert + expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument() + }) + + it('should render Stepper component with 3 steps', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert - Check for step translations + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + + it('should apply default container classes', () => { + // Arrange & Act + const { container } = renderTopBar() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('h-[52px]') + expect(wrapper).toHaveClass('shrink-0') + expect(wrapper).toHaveClass('items-center') + expect(wrapper).toHaveClass('justify-between') + expect(wrapper).toHaveClass('border-b') + expect(wrapper).toHaveClass('border-b-divider-subtle') + }) + }) + + // -------------------------------------------------------------------------- + // Props Testing - Test all prop variations + // -------------------------------------------------------------------------- + describe('Props', () => { + describe('className prop', () => { + it('should apply custom className when provided', () => { + // Arrange & Act + const { container } = renderTopBar({ className: 'custom-class' }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + + it('should merge custom className with default classes', () => { + // Arrange & Act + const { container } = renderTopBar({ className: 'my-custom-class another-class' }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('my-custom-class') + expect(wrapper).toHaveClass('another-class') + }) + + it('should render correctly without className', () => { + // Arrange & Act + const { container } = renderTopBar({ className: undefined }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + expect(wrapper).toHaveClass('flex') + }) + + it('should handle empty string className', () => { + // Arrange & Act + const { container } = renderTopBar({ className: '' }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('relative') + }) + }) + + describe('datasetId prop', () => { + it('should set fallback route to /datasets when datasetId is undefined', () => { + // Arrange & Act + renderTopBar({ datasetId: undefined }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets') + }) + + it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => { + // Arrange & Act + renderTopBar({ datasetId: 'dataset-123' }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents') + }) + + it('should handle various datasetId formats', () => { + // Arrange & Act + renderTopBar({ datasetId: 'abc-def-ghi-123' }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents') + }) + + it('should handle empty string datasetId', () => { + // Arrange & Act + renderTopBar({ datasetId: '' }) + + // Assert - Empty string is falsy, so fallback to /datasets + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets') + }) + }) + + describe('activeIndex prop', () => { + it('should pass activeIndex to Stepper component (index 0)', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert - First step should be active (has specific styling) + const steps = container.querySelectorAll('[class*="system-2xs-semibold-uppercase"]') + expect(steps.length).toBeGreaterThan(0) + }) + + it('should pass activeIndex to Stepper component (index 1)', () => { + // Arrange & Act + renderTopBar({ activeIndex: 1 }) + + // Assert - Stepper is rendered with correct props + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + }) + + it('should pass activeIndex to Stepper component (index 2)', () => { + // Arrange & Act + renderTopBar({ activeIndex: 2 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + + it('should handle edge case activeIndex of -1', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: -1 }) + + // Assert - Component should render without crashing + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle edge case activeIndex beyond steps length', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 10 }) + + // Assert - Component should render without crashing + expect(container.firstChild).toBeInTheDocument() + }) + }) + }) + + // -------------------------------------------------------------------------- + // Memoization Tests - Test useMemo logic and dependencies + // -------------------------------------------------------------------------- + describe('Memoization Logic', () => { + it('should compute fallbackRoute based on datasetId', () => { + // Arrange & Act - With datasetId + const { rerender } = render() + + // Assert + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents') + + // Act - Rerender with different datasetId + rerender() + + // Assert - Route should update + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-id/documents') + }) + + it('should update fallbackRoute when datasetId changes from undefined to defined', () => { + // Arrange + const { rerender } = render() + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') + + // Act + rerender() + + // Assert + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents') + }) + + it('should update fallbackRoute when datasetId changes from defined to undefined', () => { + // Arrange + const { rerender } = render() + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents') + + // Act + rerender() + + // Assert + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') + }) + + it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => { + // Arrange + const { rerender } = render() + const initialHref = screen.getByTestId('back-link').getAttribute('href') + + // Act + rerender() + + // Assert - href should remain the same + expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref) + }) + + it('should not change fallbackRoute when className changes but datasetId stays same', () => { + // Arrange + const { rerender } = render() + const initialHref = screen.getByTestId('back-link').getAttribute('href') + + // Act + rerender() + + // Assert - href should remain the same + expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref) + }) + }) + + // -------------------------------------------------------------------------- + // Link Component Tests + // -------------------------------------------------------------------------- + describe('Link Component', () => { + it('should render Link with replace prop', () => { + // Arrange & Act + renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('data-replace', 'true') + }) + + it('should render Link with correct classes', () => { + // Arrange & Act + renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveClass('inline-flex') + expect(backLink).toHaveClass('h-12') + expect(backLink).toHaveClass('items-center') + expect(backLink).toHaveClass('justify-start') + expect(backLink).toHaveClass('gap-1') + expect(backLink).toHaveClass('py-2') + expect(backLink).toHaveClass('pl-2') + expect(backLink).toHaveClass('pr-6') + }) + }) + + // -------------------------------------------------------------------------- + // STEP_T_MAP Tests - Verify step translations + // -------------------------------------------------------------------------- + describe('STEP_T_MAP Translations', () => { + it('should render step one translation', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + }) + + it('should render step two translation', () => { + // Arrange & Act + renderTopBar({ activeIndex: 1 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + }) + + it('should render step three translation', () => { + // Arrange & Act + renderTopBar({ activeIndex: 2 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + + it('should render all three step translations', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Edge Cases and Error Handling Tests + // -------------------------------------------------------------------------- + describe('Edge Cases', () => { + it('should handle special characters in datasetId', () => { + // Arrange & Act + renderTopBar({ datasetId: 'dataset-with-special_chars.123' }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents') + }) + + it('should handle very long datasetId', () => { + // Arrange + const longId = 'a'.repeat(100) + + // Act + renderTopBar({ datasetId: longId }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`) + }) + + it('should handle UUID format datasetId', () => { + // Arrange + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + // Act + renderTopBar({ datasetId: uuid }) + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`) + }) + + it('should handle whitespace in className', () => { + // Arrange & Act + const { container } = renderTopBar({ className: ' spaced-class ' }) + + // Assert - classNames utility handles whitespace + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toBeInTheDocument() + }) + + it('should render correctly with all props provided', () => { + // Arrange & Act + const { container } = renderTopBar({ + className: 'custom-class', + datasetId: 'full-props-id', + activeIndex: 2, + }) + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents') + }) + + it('should render correctly with minimal props (only activeIndex)', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert + expect(container.firstChild).toBeInTheDocument() + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') + }) + }) + + // -------------------------------------------------------------------------- + // Stepper Integration Tests + // -------------------------------------------------------------------------- + describe('Stepper Integration', () => { + it('should pass steps array with correct structure to Stepper', () => { + // Arrange & Act + renderTopBar({ activeIndex: 0 }) + + // Assert - All step names should be rendered + const stepOne = screen.getByText('datasetCreation.steps.one') + const stepTwo = screen.getByText('datasetCreation.steps.two') + const stepThree = screen.getByText('datasetCreation.steps.three') + + expect(stepOne).toBeInTheDocument() + expect(stepTwo).toBeInTheDocument() + expect(stepThree).toBeInTheDocument() + }) + + it('should render Stepper in centered position', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert - Check for centered positioning classes + const centeredContainer = container.querySelector('.absolute.left-1\\/2.top-1\\/2.-translate-x-1\\/2.-translate-y-1\\/2') + expect(centeredContainer).toBeInTheDocument() + }) + + it('should render step dividers between steps', () => { + // Arrange & Act + const { container } = renderTopBar({ activeIndex: 0 }) + + // Assert - Check for dividers (h-px w-4 bg-divider-deep) + const dividers = container.querySelectorAll('.h-px.w-4.bg-divider-deep') + expect(dividers.length).toBe(2) // 2 dividers between 3 steps + }) + }) + + // -------------------------------------------------------------------------- + // Accessibility Tests + // -------------------------------------------------------------------------- + describe('Accessibility', () => { + it('should have accessible back link', () => { + // Arrange & Act + renderTopBar() + + // Assert + const backLink = screen.getByTestId('back-link') + expect(backLink).toBeInTheDocument() + // Link should have visible text + expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument() + }) + + it('should have visible arrow icon in back link', () => { + // Arrange & Act + const { container } = renderTopBar() + + // Assert - Arrow icon should be visible + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + expect(arrowIcon).toHaveClass('text-text-primary') + }) + }) + + // -------------------------------------------------------------------------- + // Re-render Tests + // -------------------------------------------------------------------------- + describe('Re-render Behavior', () => { + it('should update activeIndex on re-render', () => { + // Arrange + const { rerender, container } = render() + + // Initial check + expect(container.firstChild).toBeInTheDocument() + + // Act - Update activeIndex + rerender() + + // Assert - Component should still render + expect(container.firstChild).toBeInTheDocument() + }) + + it('should update className on re-render', () => { + // Arrange + const { rerender, container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('initial-class') + + // Act + rerender() + + // Assert + expect(wrapper).toHaveClass('updated-class') + expect(wrapper).not.toHaveClass('initial-class') + }) + + it('should handle multiple rapid re-renders', () => { + // Arrange + const { rerender, container } = render() + + // Act - Multiple rapid re-renders + rerender() + rerender() + rerender() + rerender() + + // Assert - Component should be stable + expect(container.firstChild).toBeInTheDocument() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('new-class') + expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/another-id/documents') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base.spec.tsx b/web/app/components/datasets/create/website/base.spec.tsx new file mode 100644 index 0000000000..426fc259ea --- /dev/null +++ b/web/app/components/datasets/create/website/base.spec.tsx @@ -0,0 +1,555 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Input from './base/input' +import Header from './base/header' +import CrawledResult from './base/crawled-result' +import CrawledResultItem from './base/crawled-result-item' +import type { CrawlResultItem } from '@/models/datasets' + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createCrawlResultItem = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page Title', + markdown: '# Test Content', + description: 'Test description', + source_url: 'https://example.com/page', + ...overrides, +}) + +// ============================================================================ +// Input Component Tests +// ============================================================================ + +describe('Input', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const createInputProps = (overrides: Partial[0]> = {}) => ({ + value: '', + onChange: jest.fn(), + ...overrides, + }) + + describe('Rendering', () => { + it('should render text input by default', () => { + const props = createInputProps() + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'text') + }) + + it('should render number input when isNumber is true', () => { + const props = createInputProps({ isNumber: true, value: 0 }) + render() + + const input = screen.getByRole('spinbutton') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'number') + expect(input).toHaveAttribute('min', '0') + }) + + it('should render with placeholder', () => { + const props = createInputProps({ placeholder: 'Enter URL' }) + render() + + expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument() + }) + + it('should render with initial value', () => { + const props = createInputProps({ value: 'test value' }) + render() + + expect(screen.getByDisplayValue('test value')).toBeInTheDocument() + }) + }) + + describe('Text Input Behavior', () => { + it('should call onChange with string value for text input', async () => { + const onChange = jest.fn() + const props = createInputProps({ onChange }) + + render() + const input = screen.getByRole('textbox') + + await userEvent.type(input, 'hello') + + expect(onChange).toHaveBeenCalledWith('h') + expect(onChange).toHaveBeenCalledWith('e') + expect(onChange).toHaveBeenCalledWith('l') + expect(onChange).toHaveBeenCalledWith('l') + expect(onChange).toHaveBeenCalledWith('o') + }) + }) + + describe('Number Input Behavior', () => { + it('should call onChange with parsed integer for number input', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '42' } }) + + expect(onChange).toHaveBeenCalledWith(42) + }) + + it('should call onChange with empty string when input is NaN', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: 'abc' } }) + + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should call onChange with empty string when input is empty', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 5 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '' } }) + + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should clamp negative values to MIN_VALUE (0)', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '-5' } }) + + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should handle decimal input by parsing as integer', () => { + const onChange = jest.fn() + const props = createInputProps({ isNumber: true, onChange, value: 0 }) + + render() + const input = screen.getByRole('spinbutton') + + fireEvent.change(input, { target: { value: '3.7' } }) + + expect(onChange).toHaveBeenCalledWith(3) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(Input.$$typeof).toBeDefined() + }) + }) +}) + +// ============================================================================ +// Header Component Tests +// ============================================================================ + +describe('Header', () => { + const createHeaderProps = (overrides: Partial[0]> = {}) => ({ + title: 'Test Title', + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + ...overrides, + }) + + describe('Rendering', () => { + it('should render title', () => { + const props = createHeaderProps() + render(
) + + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('should render doc link', () => { + const props = createHeaderProps() + render(
) + + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://docs.example.com') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should render button text when not in pipeline', () => { + const props = createHeaderProps({ buttonText: 'Configure' }) + render(
) + + expect(screen.getByText('Configure')).toBeInTheDocument() + }) + + it('should not render button text when in pipeline', () => { + const props = createHeaderProps({ isInPipeline: true, buttonText: 'Configure' }) + render(
) + + expect(screen.queryByText('Configure')).not.toBeInTheDocument() + }) + }) + + describe('isInPipeline Prop', () => { + it('should apply pipeline styles when isInPipeline is true', () => { + const props = createHeaderProps({ isInPipeline: true }) + render(
) + + const titleElement = screen.getByText('Test Title') + expect(titleElement).toHaveClass('system-sm-semibold') + }) + + it('should apply default styles when isInPipeline is false', () => { + const props = createHeaderProps({ isInPipeline: false }) + render(
) + + const titleElement = screen.getByText('Test Title') + expect(titleElement).toHaveClass('system-md-semibold') + }) + + it('should apply compact button styles when isInPipeline is true', () => { + const props = createHeaderProps({ isInPipeline: true }) + render(
) + + const button = screen.getByRole('button') + expect(button).toHaveClass('size-6') + expect(button).toHaveClass('px-1') + }) + + it('should apply default button styles when isInPipeline is false', () => { + const props = createHeaderProps({ isInPipeline: false }) + render(
) + + const button = screen.getByRole('button') + expect(button).toHaveClass('gap-x-0.5') + expect(button).toHaveClass('px-1.5') + }) + }) + + describe('User Interactions', () => { + it('should call onClickConfiguration when button is clicked', async () => { + const onClickConfiguration = jest.fn() + const props = createHeaderProps({ onClickConfiguration }) + + render(
) + await userEvent.click(screen.getByRole('button')) + + expect(onClickConfiguration).toHaveBeenCalledTimes(1) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(Header.$$typeof).toBeDefined() + }) + }) +}) + +// ============================================================================ +// CrawledResultItem Component Tests +// ============================================================================ + +describe('CrawledResultItem', () => { + const createItemProps = (overrides: Partial[0]> = {}) => ({ + payload: createCrawlResultItem(), + isChecked: false, + isPreview: false, + onCheckChange: jest.fn(), + onPreview: jest.fn(), + testId: 'test-item', + ...overrides, + }) + + describe('Rendering', () => { + it('should render title and source URL', () => { + const props = createItemProps({ + payload: createCrawlResultItem({ + title: 'My Page', + source_url: 'https://mysite.com', + }), + }) + render() + + expect(screen.getByText('My Page')).toBeInTheDocument() + expect(screen.getByText('https://mysite.com')).toBeInTheDocument() + }) + + it('should render checkbox (custom Checkbox component)', () => { + const props = createItemProps() + render() + + // Find checkbox by data-testid + const checkbox = screen.getByTestId('checkbox-test-item') + expect(checkbox).toBeInTheDocument() + }) + + it('should render preview button', () => { + const props = createItemProps() + render() + + expect(screen.getByText('datasetCreation.stepOne.website.preview')).toBeInTheDocument() + }) + }) + + describe('Checkbox Behavior', () => { + it('should call onCheckChange with true when unchecked item is clicked', async () => { + const onCheckChange = jest.fn() + const props = createItemProps({ isChecked: false, onCheckChange }) + + render() + const checkbox = screen.getByTestId('checkbox-test-item') + await userEvent.click(checkbox) + + expect(onCheckChange).toHaveBeenCalledWith(true) + }) + + it('should call onCheckChange with false when checked item is clicked', async () => { + const onCheckChange = jest.fn() + const props = createItemProps({ isChecked: true, onCheckChange }) + + render() + const checkbox = screen.getByTestId('checkbox-test-item') + await userEvent.click(checkbox) + + expect(onCheckChange).toHaveBeenCalledWith(false) + }) + }) + + describe('Preview Behavior', () => { + it('should call onPreview when preview button is clicked', async () => { + const onPreview = jest.fn() + const props = createItemProps({ onPreview }) + + render() + await userEvent.click(screen.getByText('datasetCreation.stepOne.website.preview')) + + expect(onPreview).toHaveBeenCalledTimes(1) + }) + + it('should apply active style when isPreview is true', () => { + const props = createItemProps({ isPreview: true }) + const { container } = render() + + const wrapper = container.firstChild + expect(wrapper).toHaveClass('bg-state-base-active') + }) + + it('should not apply active style when isPreview is false', () => { + const props = createItemProps({ isPreview: false }) + const { container } = render() + + const wrapper = container.firstChild + expect(wrapper).not.toHaveClass('bg-state-base-active') + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(CrawledResultItem.$$typeof).toBeDefined() + }) + }) +}) + +// ============================================================================ +// CrawledResult Component Tests +// ============================================================================ + +describe('CrawledResult', () => { + const createResultProps = (overrides: Partial[0]> = {}) => ({ + list: [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }), + ], + checkedList: [], + onSelectedChange: jest.fn(), + onPreview: jest.fn(), + usedTime: 2.5, + ...overrides, + }) + + // Helper functions to get checkboxes by data-testid + const getSelectAllCheckbox = () => screen.getByTestId('checkbox-select-all') + const getItemCheckbox = (index: number) => screen.getByTestId(`checkbox-item-${index}`) + + describe('Rendering', () => { + it('should render all items in list', () => { + const props = createResultProps() + render() + + expect(screen.getByText('Page 1')).toBeInTheDocument() + expect(screen.getByText('Page 2')).toBeInTheDocument() + expect(screen.getByText('Page 3')).toBeInTheDocument() + }) + + it('should render time info', () => { + const props = createResultProps({ usedTime: 3.456 }) + render() + + // The component uses i18n, so we check for the key pattern + expect(screen.getByText(/scrapTimeInfo/)).toBeInTheDocument() + }) + + it('should render select all checkbox', () => { + const props = createResultProps() + render() + + expect(screen.getByText('datasetCreation.stepOne.website.selectAll')).toBeInTheDocument() + }) + + it('should render reset all when all items are checked', () => { + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com' }), + createCrawlResultItem({ source_url: 'https://page2.com' }), + ] + const props = createResultProps({ list, checkedList: list }) + render() + + expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument() + }) + }) + + describe('Select All / Deselect All', () => { + it('should call onSelectedChange with all items when select all is clicked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com' }), + createCrawlResultItem({ source_url: 'https://page2.com' }), + ] + const props = createResultProps({ list, checkedList: [], onSelectedChange }) + + render() + await userEvent.click(getSelectAllCheckbox()) + + expect(onSelectedChange).toHaveBeenCalledWith(list) + }) + + it('should call onSelectedChange with empty array when reset all is clicked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com' }), + createCrawlResultItem({ source_url: 'https://page2.com' }), + ] + const props = createResultProps({ list, checkedList: list, onSelectedChange }) + + render() + await userEvent.click(getSelectAllCheckbox()) + + expect(onSelectedChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Individual Item Selection', () => { + it('should add item to checkedList when unchecked item is checked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, checkedList: [], onSelectedChange }) + + render() + await userEvent.click(getItemCheckbox(0)) + + expect(onSelectedChange).toHaveBeenCalledWith([list[0]]) + }) + + it('should remove item from checkedList when checked item is unchecked', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, checkedList: [list[0]], onSelectedChange }) + + render() + await userEvent.click(getItemCheckbox(0)) + + expect(onSelectedChange).toHaveBeenCalledWith([]) + }) + + it('should preserve other checked items when unchecking one item', async () => { + const onSelectedChange = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }), + ] + const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange }) + + render() + // Click the first item's checkbox to uncheck it + await userEvent.click(getItemCheckbox(0)) + + expect(onSelectedChange).toHaveBeenCalledWith([list[1]]) + }) + }) + + describe('Preview Behavior', () => { + it('should call onPreview with correct item when preview is clicked', async () => { + const onPreview = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, onPreview }) + + render() + + // Click preview on second item + const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') + await userEvent.click(previewButtons[1]) + + expect(onPreview).toHaveBeenCalledWith(list[1]) + }) + + it('should track preview index correctly', async () => { + const onPreview = jest.fn() + const list = [ + createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }), + createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }), + ] + const props = createResultProps({ list, onPreview }) + + render() + + // Click preview on first item + const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') + await userEvent.click(previewButtons[0]) + + expect(onPreview).toHaveBeenCalledWith(list[0]) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(CrawledResult.$$typeof).toBeDefined() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty list', () => { + const props = createResultProps({ list: [], checkedList: [] }) + render() + + // Should still render the header with resetAll (empty list = all checked) + expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument() + }) + + it('should handle className prop', () => { + const props = createResultProps({ className: 'custom-class' }) + const { container } = render() + + expect(container.firstChild).toHaveClass('custom-class') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx index f5451af074..d5be00354a 100644 --- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx @@ -12,6 +12,7 @@ type Props = { label: string labelClassName?: string tooltip?: string + testId?: string } const CheckboxWithLabel: FC = ({ @@ -21,10 +22,11 @@ const CheckboxWithLabel: FC = ({ label, labelClassName, tooltip, + testId, }) => { return (