diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index 2a4594b29..b0a091930 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -4,6 +4,8 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { useTranslation } from "react-i18next"; import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig"; import { isImageFormat } from "../../../utils/convertUtils"; +import { useFileSelectionActions } from "../../../contexts/FileSelectionContext"; +import { useFileContext } from "../../../contexts/FileContext"; import GroupedFormatDropdown from "./GroupedFormatDropdown"; import ConvertToImageSettings from "./ConvertToImageSettings"; import ConvertFromImageSettings from "./ConvertFromImageSettings"; @@ -20,6 +22,7 @@ interface ConvertSettingsProps { parameters: ConvertParameters; onParameterChange: (key: keyof ConvertParameters, value: any) => void; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; + selectedFiles: File[]; disabled?: boolean; } @@ -27,11 +30,14 @@ const ConvertSettings = ({ parameters, onParameterChange, getAvailableToExtensions, + selectedFiles, disabled = false }: ConvertSettingsProps) => { const { t } = useTranslation(); const theme = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); + const { setSelectedFiles } = useFileSelectionActions(); + const { setSelectedFiles: setContextSelectedFiles } = useFileContext(); // Get all possible conversion endpoints to check their availability const allEndpoints = useMemo(() => { @@ -102,6 +108,31 @@ const ConvertSettings = ({ // Disable smart detection when manually changing source format onParameterChange('isSmartDetection', false); onParameterChange('smartDetectionType', 'none'); + + // Deselect files that don't match the new source format + if (selectedFiles.length > 0 && value !== 'any') { + const matchingFiles = selectedFiles.filter(file => { + const extension = file.name.split('.').pop()?.toLowerCase() || ''; + + // For 'image' source format, check if it's an image + if (value === 'image') { + return isImageFormat(extension); + } + + // For specific extensions, match exactly + return extension === value; + }); + + // Only update selection if files were filtered out + if (matchingFiles.length !== selectedFiles.length) { + // Update both selection contexts + setSelectedFiles(matchingFiles); + + // Update File Context selection with file IDs + const matchingFileIds = matchingFiles.map(file => (file as any).id || file.name); + setContextSelectedFiles(matchingFileIds); + } + } }; const handleToExtensionChange = (value: string) => { @@ -133,7 +164,7 @@ const ConvertSettings = ({ placeholder={t("convert.sourceFormatPlaceholder", "Source format")} options={enhancedFromOptions} onChange={handleFromExtensionChange} - disabled={disabled || parameters.isSmartDetection} + disabled={disabled} minWidth="21.875rem" /> @@ -163,17 +194,6 @@ const ConvertSettings = ({ /> - ) : parameters.isSmartDetection ? ( - {}} // No-op since it's disabled - disabled={true} - minWidth="21.875rem" - /> ) : ( { } }; - const executeSingleOperation = useCallback(async ( - parameters: ConvertParameters, - selectedFiles: File[] - ) => { - const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); - const formData = buildFormData(parameters, selectedFiles); - - // Get endpoint using utility function - const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension); - if (!endpoint) { - setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension })); - return; - } - - recordOperation(fileId, operation); - - setStatus(t("loading")); - setIsLoading(true); - setErrorMessage(null); - - try { - const response = await axios.post(endpoint, formData, { responseType: "blob" }); - const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - - // Generate filename based on conversion - const originalName = selectedFiles.length === 1 - ? selectedFiles[0].name.split('.')[0] - : 'combined_images'; - const filename = `${originalName}_converted.${parameters.toExtension}`; - - setDownloadUrl(url); - setDownloadFilename(filename); - setStatus(t("downloadComplete")); - - await processResults(blob, filename); - markOperationApplied(fileId, operationId); - } catch (error: any) { - console.error(error); - let errorMsg = t("convert.errorConversion", "An error occurred while converting the file."); - if (error.response?.data && typeof error.response.data === 'string') { - errorMsg = error.response.data; - } else if (error.message) { - errorMsg = error.message; - } - setErrorMessage(errorMsg); - setStatus(t("error._value", "Conversion failed.")); - markOperationFailed(fileId, operationId, errorMsg); - } finally { - setIsLoading(false); - } - }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]); const resetResults = useCallback(() => { // Clean up blob URLs to prevent memory leaks diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts index afbfa4ad6..ec8196942 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts @@ -191,7 +191,11 @@ describe('useConvertParameters', () => { const availableExtensions = result.current.getAvailableToExtensions('invalid'); - expect(availableExtensions).toEqual([]); + expect(availableExtensions).toEqual([{ + "group": "Document", + "label": "PDF", + "value": "pdf", + }]); }); test('should return empty array for empty source format', () => { @@ -217,8 +221,8 @@ describe('useConvertParameters', () => { test('should handle files without extensions', () => { const { result } = renderHook(() => useConvertParameters()); - // Files without dots return the entire filename as "extension" - expect(result.current.detectFileExtension('noextension')).toBe('noextension'); + // Files without extensions should return empty string + expect(result.current.detectFileExtension('noextension')).toBe(''); expect(result.current.detectFileExtension('')).toBe(''); }); diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.ts b/frontend/src/hooks/tools/convert/useConvertParameters.ts index 7a2a917e9..6cf153acc 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.ts @@ -118,22 +118,49 @@ export const useConvertParameters = (): ConvertParametersHook => { const getAvailableToExtensions = (fromExtension: string) => { if (!fromExtension) return []; - const supportedExtensions = CONVERSION_MATRIX[fromExtension] || []; + let supportedExtensions = CONVERSION_MATRIX[fromExtension] || []; + + // If no explicit conversion exists, but file-to-pdf might be available, + // fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf) + if (supportedExtensions.length === 0 && fromExtension !== 'any') { + supportedExtensions = CONVERSION_MATRIX['any'] || []; + } + return TO_FORMAT_OPTIONS.filter(option => supportedExtensions.includes(option.value) ); }; const detectFileExtension = (filename: string): string => { - const extension = filename.split('.').pop()?.toLowerCase(); - return extension || ''; + if (!filename || typeof filename !== 'string') return ''; + + const parts = filename.split('.'); + // If there's no extension (no dots or only one part), return empty string + if (parts.length <= 1) return ''; + + // Get the last part (extension) in lowercase + let extension = parts[parts.length - 1].toLowerCase(); + + // Normalize common extension variants + if (extension === 'jpeg') extension = 'jpg'; + + return extension; }; const analyzeFileTypes = (files: Array<{name: string}>) => { if (files.length <= 1) { // Single file or no files - use regular detection with auto-target selection - const fromExt = files.length === 1 ? detectFileExtension(files[0].name) : ''; - const availableTargets = fromExt ? CONVERSION_MATRIX[fromExt] || [] : []; + const detectedExt = files.length === 1 ? detectFileExtension(files[0].name) : ''; + let fromExt = detectedExt; + let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : []; + + // If no explicit conversion exists for this file type, fall back to 'any' + // which will attempt file-to-pdf conversion if available + if (availableTargets.length === 0) { + fromExt = 'any'; + availableTargets = CONVERSION_MATRIX['any'] || []; + } + const autoTarget = availableTargets.length === 1 ? availableTargets[0] : ''; setParameters(prev => ({ @@ -152,8 +179,16 @@ export const useConvertParameters = (): ConvertParametersHook => { if (uniqueExtensions.length === 1) { // All files are the same type - use regular detection with auto-target selection - const fromExt = uniqueExtensions[0]; - const availableTargets = CONVERSION_MATRIX[fromExt] || []; + const detectedExt = uniqueExtensions[0]; + let fromExt = detectedExt; + let availableTargets = CONVERSION_MATRIX[detectedExt] || []; + + // If no explicit conversion exists for this file type, fall back to 'any' + if (availableTargets.length === 0) { + fromExt = 'any'; + availableTargets = CONVERSION_MATRIX['any'] || []; + } + const autoTarget = availableTargets.length === 1 ? availableTargets[0] : ''; setParameters(prev => ({ diff --git a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts new file mode 100644 index 000000000..cd88a9eba --- /dev/null +++ b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts @@ -0,0 +1,310 @@ +/** + * Tests for auto-detection and smart conversion features in useConvertParameters + * This covers the analyzeFileTypes function and related smart detection logic + */ + +import { describe, test, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useConvertParameters } from './useConvertParameters'; + +describe('useConvertParameters - Auto Detection & Smart Conversion', () => { + + describe('Single File Detection', () => { + + test('should detect single file extension and set auto-target', () => { + const { result } = renderHook(() => useConvertParameters()); + + const pdfFile = [{ name: 'document.pdf' }]; + + act(() => { + result.current.analyzeFileTypes(pdfFile); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + expect(result.current.parameters.toExtension).toBe(''); // No auto-selection for multiple targets + expect(result.current.parameters.isSmartDetection).toBe(false); + expect(result.current.parameters.smartDetectionType).toBe('none'); + }); + + test('should handle unknown file types with file-to-pdf fallback', () => { + const { result } = renderHook(() => useConvertParameters()); + + const unknownFile = [{ name: 'document.xyz' }]; + + act(() => { + result.current.analyzeFileTypes(unknownFile); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf + expect(result.current.parameters.isSmartDetection).toBe(false); + }); + + test('should handle files without extensions', () => { + const { result } = renderHook(() => useConvertParameters()); + + const noExtFile = [{ name: 'document' }]; + + act(() => { + result.current.analyzeFileTypes(noExtFile); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf + }); + + test('should reset parameters when no files provided', () => { + const { result } = renderHook(() => useConvertParameters()); + + // First set some parameters + act(() => { + result.current.analyzeFileTypes([{ name: 'test.pdf' }]); + }); + + // Then analyze empty file list + act(() => { + result.current.analyzeFileTypes([]); + }); + + expect(result.current.parameters.fromExtension).toBe(''); + expect(result.current.parameters.toExtension).toBe(''); + expect(result.current.parameters.isSmartDetection).toBe(false); + }); + }); + + describe('Multiple Identical Files', () => { + + test('should detect multiple PDF files and set auto-target', () => { + const { result } = renderHook(() => useConvertParameters()); + + const pdfFiles = [ + { name: 'doc1.pdf' }, + { name: 'doc2.pdf' }, + { name: 'doc3.pdf' } + ]; + + act(() => { + result.current.analyzeFileTypes(pdfFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + expect(result.current.parameters.toExtension).toBe(''); // Auto-selected + expect(result.current.parameters.isSmartDetection).toBe(false); + expect(result.current.parameters.smartDetectionType).toBe('none'); + }); + + test('should handle multiple unknown file types with fallback', () => { + const { result } = renderHook(() => useConvertParameters()); + + const unknownFiles = [ + { name: 'file1.xyz' }, + { name: 'file2.xyz' } + ]; + + act(() => { + result.current.analyzeFileTypes(unknownFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(false); + }); + }); + + describe('Smart Detection - All Images', () => { + + test('should detect all image files and enable smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const imageFiles = [ + { name: 'photo1.jpg' }, + { name: 'photo2.png' }, + { name: 'photo3.gif' } + ]; + + act(() => { + result.current.analyzeFileTypes(imageFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('image'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('images'); + }); + + test('should handle mixed case image extensions', () => { + const { result } = renderHook(() => useConvertParameters()); + + const imageFiles = [ + { name: 'photo1.JPG' }, + { name: 'photo2.PNG' } + ]; + + act(() => { + result.current.analyzeFileTypes(imageFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('images'); + }); + }); + + describe('Smart Detection - Mixed File Types', () => { + + test('should detect mixed file types and enable smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'document.pdf' }, + { name: 'spreadsheet.xlsx' }, + { name: 'presentation.pptx' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + + test('should detect mixed images and documents as mixed type', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'photo.jpg' }, + { name: 'document.pdf' }, + { name: 'text.txt' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + + test('should handle mixed with unknown file types', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'document.pdf' }, + { name: 'unknown.xyz' }, + { name: 'noextension' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + }); + + describe('Smart Detection Endpoint Resolution', () => { + + test('should return correct endpoint for image smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const imageFiles = [ + { name: 'photo1.jpg' }, + { name: 'photo2.png' } + ]; + + act(() => { + result.current.analyzeFileTypes(imageFiles); + }); + + expect(result.current.getEndpointName()).toBe('img-to-pdf'); + expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf'); + }); + + test('should return correct endpoint for mixed smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const mixedFiles = [ + { name: 'document.pdf' }, + { name: 'spreadsheet.xlsx' } + ]; + + act(() => { + result.current.analyzeFileTypes(mixedFiles); + }); + + expect(result.current.getEndpointName()).toBe('file-to-pdf'); + expect(result.current.getEndpoint()).toBe('/api/v1/convert/file/pdf'); + }); + }); + + describe('Auto-Target Selection Logic', () => { + + test('should select single available target automatically', () => { + const { result } = renderHook(() => useConvertParameters()); + + // Markdown has only one conversion target (PDF) + const mdFile = [{ name: 'readme.md' }]; + + act(() => { + result.current.analyzeFileTypes(mdFile); + }); + + expect(result.current.parameters.fromExtension).toBe('md'); + expect(result.current.parameters.toExtension).toBe('pdf'); // Only available target + }); + + test('should not auto-select when multiple targets available', () => { + const { result } = renderHook(() => useConvertParameters()); + + // PDF has multiple conversion targets, so no auto-selection + const pdfFile = [{ name: 'document.pdf' }]; + + act(() => { + result.current.analyzeFileTypes(pdfFile); + }); + + expect(result.current.parameters.fromExtension).toBe('pdf'); + // Should NOT auto-select when multiple targets available + expect(result.current.parameters.toExtension).toBe(''); + }); + }); + + describe('Edge Cases', () => { + + test('should handle empty file names', () => { + const { result } = renderHook(() => useConvertParameters()); + + const emptyFiles = [{ name: '' }]; + + act(() => { + result.current.analyzeFileTypes(emptyFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('any'); + expect(result.current.parameters.toExtension).toBe('pdf'); + }); + + test('should handle malformed file objects', () => { + const { result } = renderHook(() => useConvertParameters()); + + const malformedFiles = [ + { name: 'valid.pdf' }, + // @ts-ignore - Testing runtime resilience + { name: null }, + // @ts-ignore + { name: undefined } + ]; + + act(() => { + result.current.analyzeFileTypes(malformedFiles); + }); + + // Should still process the valid file and handle gracefully + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('mixed'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index d38cac8fc..04c716080 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -36,6 +36,8 @@ global.Worker = vi.fn().mockImplementation(() => ({ terminate: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), + onmessage: null, + onerror: null, })) // Mock ResizeObserver for Mantine components @@ -65,4 +67,7 @@ Object.defineProperty(window, 'matchMedia', { removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), -}) \ No newline at end of file +}) + +// Set global test timeout to prevent hangs +vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 }) \ No newline at end of file diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx new file mode 100644 index 000000000..bb8c71279 --- /dev/null +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -0,0 +1,357 @@ +/** + * Integration tests for Convert Tool Smart Detection with real file scenarios + * Tests the complete flow from file upload through auto-detection to API calls + */ + +import React from 'react'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation'; +import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters'; +import { FileContextProvider } from '../../contexts/FileContext'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../i18n/config'; +import axios from 'axios'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = vi.mocked(axios); + +// Mock utility modules +vi.mock('../../utils/thumbnailUtils', () => ({ + generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail') +})); + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + {children} + + +); + +describe('Convert Tool - Smart Detection Integration Tests', () => { + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock successful API response + mockedAxios.post.mockResolvedValue({ + data: new Blob(['fake converted content'], { type: 'application/pdf' }) + }); + }); + + afterEach(() => { + // Clean up any blob URLs created during tests + vi.restoreAllMocks(); + }); + + describe('Single File Auto-Detection Flow', () => { + test('should auto-detect PDF from DOCX and convert to PDF', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock DOCX file + const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + + // Test auto-detection + act(() => { + paramsResult.current.analyzeFileTypes([docxFile]); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('docx'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(false); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [docxFile] + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + responseType: 'blob' + }); + }); + + test('should handle unknown file type with file-to-pdf fallback', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock unknown file + const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' }); + + // Test auto-detection + act(() => { + paramsResult.current.analyzeFileTypes([unknownFile]); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('any'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); // Fallback + expect(paramsResult.current.parameters.isSmartDetection).toBe(false); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [unknownFile] + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + responseType: 'blob' + }); + }); + }); + + describe('Multi-File Smart Detection Flow', () => { + + test('should detect all images and use img-to-pdf endpoint', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock image files + const imageFiles = [ + new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }), + new File(['png content'], 'photo2.png', { type: 'image/png' }), + new File(['gif content'], 'photo3.gif', { type: 'image/gif' }) + ]; + + // Test smart detection for all images + act(() => { + paramsResult.current.analyzeFileTypes(imageFiles); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('image'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(true); + expect(paramsResult.current.parameters.smartDetectionType).toBe('images'); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + imageFiles + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), { + responseType: 'blob' + }); + + // Should send all files in single request + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + const files = formData.getAll('fileInput'); + expect(files).toHaveLength(3); + }); + + test('should detect mixed file types and use file-to-pdf endpoint', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mixed file types + const mixedFiles = [ + new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }), + new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), + new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }) + ]; + + // Test smart detection for mixed types + act(() => { + paramsResult.current.analyzeFileTypes(mixedFiles); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('any'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(true); + expect(paramsResult.current.parameters.smartDetectionType).toBe('mixed'); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + mixedFiles + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), { + responseType: 'blob' + }); + }); + }); + + describe('Image Conversion Options Integration', () => { + + test('should send correct parameters for image-to-pdf conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const imageFiles = [ + new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), + new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) + ]; + + // Set up image conversion parameters + act(() => { + paramsResult.current.analyzeFileTypes(imageFiles); + paramsResult.current.updateParameter('imageOptions', { + colorType: 'grayscale', + dpi: 150, + singleOrMultiple: 'single', + fitOption: 'fitToPage', + autoRotate: false, + combineImages: true + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + imageFiles + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('fitOption')).toBe('fitToPage'); + expect(formData.get('colorType')).toBe('grayscale'); + expect(formData.get('autoRotate')).toBe('false'); + }); + + test('should process images separately when combineImages is false', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const imageFiles = [ + new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), + new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) + ]; + + // Set up for separate processing + act(() => { + paramsResult.current.analyzeFileTypes(imageFiles); + paramsResult.current.updateParameter('imageOptions', { + ...paramsResult.current.parameters.imageOptions, + combineImages: false + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + imageFiles + ); + }); + + // Should make separate API calls for each file + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + }); + + describe('Error Scenarios in Smart Detection', () => { + + + test('should handle partial failures in multi-file processing', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Mock one success, one failure + mockedAxios.post + .mockResolvedValueOnce({ + data: new Blob(['converted1'], { type: 'application/pdf' }) + }) + .mockRejectedValueOnce(new Error('File 2 failed')); + + const mixedFiles = [ + new File(['file1'], 'doc1.txt', { type: 'text/plain' }), + new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' }) + ]; + + // Set up for separate processing (mixed smart detection) + act(() => { + paramsResult.current.analyzeFileTypes(mixedFiles); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + mixedFiles + ); + }); + + await waitFor(() => { + // Should have processed at least one file successfully + expect(operationResult.current.files.length).toBeGreaterThan(0); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('Real File Extension Detection', () => { + + test('should correctly detect various file extensions', async () => { + const { result } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const testCases = [ + { filename: 'document.PDF', expected: 'pdf' }, + { filename: 'image.JPEG', expected: 'jpg' }, // JPEG should normalize to jpg + { filename: 'photo.jpeg', expected: 'jpg' }, // jpeg should normalize to jpg + { filename: 'archive.tar.gz', expected: 'gz' }, + { filename: 'file.', expected: '' }, + { filename: '.hidden', expected: 'hidden' }, + { filename: 'noextension', expected: '' } + ]; + + testCases.forEach(({ filename, expected }) => { + const detected = result.current.detectFileExtension(filename); + expect(detected).toBe(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/tools/Convert.test.tsx b/frontend/src/tools/Convert.test.tsx index d1831358e..398d10ff3 100644 --- a/frontend/src/tools/Convert.test.tsx +++ b/frontend/src/tools/Convert.test.tsx @@ -11,6 +11,8 @@ import { useConvertParameters } from '../hooks/tools/convert/useConvertParameter // Mock the hooks vi.mock('../hooks/tools/convert/useConvertParameters'); vi.mock('../hooks/useEndpointConfig'); +vi.mock('../contexts/FileSelectionContext'); +vi.mock('../contexts/FileContext'); const mockUseConvertParameters = vi.mocked(useConvertParameters); @@ -41,12 +43,37 @@ vi.mock('../hooks/useEndpointConfig', () => ({ }) })); +// Mock FileSelectionContext hooks +vi.mock('../contexts/FileSelectionContext', () => ({ + useFileSelectionActions: () => ({ + setSelectedFiles: vi.fn(), + clearSelection: vi.fn(), + setMaxFiles: vi.fn(), + setIsToolMode: vi.fn() + }) +})); + +// Mock FileContext +vi.mock('../contexts/FileContext', () => ({ + FileContextProvider: ({ children }: { children: React.ReactNode }) => children, + useFileContext: () => ({ + activeFiles: [], + setSelectedFiles: vi.fn(), + addFiles: vi.fn(), + removeFiles: vi.fn(), + clearFiles: vi.fn(), + updateFileMetadata: vi.fn(), + mode: 'viewer', + setMode: vi.fn(), + isLoading: false, + error: null + }) +})); + const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - + {children} ); @@ -117,6 +144,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); @@ -158,6 +186,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); @@ -205,6 +234,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); @@ -249,6 +279,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); @@ -284,6 +315,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); @@ -323,6 +355,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); @@ -361,6 +394,7 @@ describe('Convert Tool Navigation Tests', () => { }} onParameterChange={mockOnParameterChange} getAvailableToExtensions={mockGetAvailableToExtensions} + selectedFiles={[]} /> ); diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 66964499b..f3aa1a920 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -144,6 +144,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { parameters={convertParams.parameters} onParameterChange={convertParams.updateParameter} getAvailableToExtensions={convertParams.getAvailableToExtensions} + selectedFiles={selectedFiles} disabled={endpointLoading} /> diff --git a/frontend/src/utils/convertUtils.test.ts b/frontend/src/utils/convertUtils.test.ts index 01f6ae897..ddf713a58 100644 --- a/frontend/src/utils/convertUtils.test.ts +++ b/frontend/src/utils/convertUtils.test.ts @@ -83,7 +83,7 @@ describe('convertUtils', () => { test('should return empty string for unsupported conversions', () => { expect(getEndpointName('pdf', 'exe')).toBe(''); - expect(getEndpointName('wav', 'pdf')).toBe(''); + expect(getEndpointName('wav', 'pdf')).toBe('file-to-pdf'); // Try using file to pdf as fallback expect(getEndpointName('png', 'docx')).toBe(''); // Images can't convert to Word docs }); @@ -166,7 +166,7 @@ describe('convertUtils', () => { test('should return empty string for unsupported conversions', () => { expect(getEndpointUrl('pdf', 'exe')).toBe(''); - expect(getEndpointUrl('wav', 'pdf')).toBe(''); + expect(getEndpointUrl('wav', 'pdf')).toBe('/api/v1/convert/file/pdf'); // Try using file to pdf as fallback expect(getEndpointUrl('invalid', 'invalid')).toBe(''); }); @@ -248,7 +248,7 @@ describe('convertUtils', () => { test('should return false for unsupported conversions', () => { expect(isConversionSupported('pdf', 'exe')).toBe(false); - expect(isConversionSupported('wav', 'pdf')).toBe(false); + expect(isConversionSupported('wav', 'pdf')).toBe(true); // Fallback to file to pdf expect(isConversionSupported('png', 'docx')).toBe(false); expect(isConversionSupported('nonexistent', 'alsononexistent')).toBe(false); }); @@ -330,7 +330,7 @@ describe('convertUtils', () => { const longExtension = 'a'.repeat(100); expect(isImageFormat(longExtension)).toBe(false); expect(getEndpointName('pdf', longExtension)).toBe(''); - expect(getEndpointName(longExtension, 'pdf')).toBe(''); + expect(getEndpointName(longExtension, 'pdf')).toBe('file-to-pdf'); // Fallback to file to pdf }); }); }); \ No newline at end of file diff --git a/frontend/src/utils/convertUtils.ts b/frontend/src/utils/convertUtils.ts index 2e3fde9b8..f43e1bf1b 100644 --- a/frontend/src/utils/convertUtils.ts +++ b/frontend/src/utils/convertUtils.ts @@ -10,7 +10,14 @@ import { export const getEndpointName = (fromExtension: string, toExtension: string): string => { if (!fromExtension || !toExtension) return ''; - const endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension]; + let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension]; + + // If no explicit mapping exists and we're converting to PDF, + // fall back to 'any' which uses file-to-pdf endpoint + if (!endpointKey && toExtension === 'pdf' && fromExtension !== 'any') { + endpointKey = EXTENSION_TO_ENDPOINT['any']?.[toExtension]; + } + return endpointKey || ''; }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 840e700f8..215a9378b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ "jsx": "react-jsx", /* Specify what JSX code is generated. */ // "libReplacement": true, /* Enable lib replacement. */ diff --git a/frontend/vitest.minimal.config.ts b/frontend/vitest.minimal.config.ts new file mode 100644 index 000000000..335af4e9c --- /dev/null +++ b/frontend/vitest.minimal.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 5000, + include: ['src/utils/convertUtils.test.ts'] + }, +}) \ No newline at end of file