This commit is contained in:
Connor Yoh 2025-07-30 20:03:11 +01:00
parent 8dff995a1c
commit 32825acc9b
13 changed files with 814 additions and 84 deletions

View File

@ -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"
/>
</Stack>
@ -163,17 +194,6 @@ const ConvertSettings = ({
/>
</Group>
</UnstyledButton>
) : parameters.isSmartDetection ? (
<GroupedFormatDropdown
name="convert-to-dropdown"
data-testid="to-format-dropdown"
value="pdf"
placeholder="PDF"
options={[{ value: 'pdf', label: 'PDF', group: 'Document' }]}
onChange={() => {}} // No-op since it's disabled
disabled={true}
minWidth="21.875rem"
/>
) : (
<GroupedFormatDropdown
name="convert-to-dropdown"

View File

@ -313,58 +313,6 @@ export const useConvertOperation = (): ConvertOperationHook => {
}
};
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

View File

@ -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('');
});

View File

@ -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 => ({

View File

@ -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');
});
});
});

View File

@ -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
@ -66,3 +68,6 @@ Object.defineProperty(window, 'matchMedia', {
dispatchEvent: vi.fn(),
})),
})
// Set global test timeout to prevent hangs
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 })

View File

@ -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 }) => (
<I18nextProvider i18n={i18n}>
<FileContextProvider>
{children}
</FileContextProvider>
</I18nextProvider>
);
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);
});
});
});
});

View File

@ -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 }) => (
<MantineProvider>
<I18nextProvider i18n={i18n}>
<FileContextProvider>
{children}
</FileContextProvider>
{children}
</I18nextProvider>
</MantineProvider>
);
@ -117,6 +144,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);
@ -158,6 +186,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);
@ -205,6 +234,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);
@ -249,6 +279,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);
@ -284,6 +315,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);
@ -323,6 +355,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);
@ -361,6 +394,7 @@ describe('Convert Tool Navigation Tests', () => {
}}
onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions}
selectedFiles={[]}
/>
</TestWrapper>
);

View File

@ -144,6 +144,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
parameters={convertParams.parameters}
onParameterChange={convertParams.updateParameter}
getAvailableToExtensions={convertParams.getAvailableToExtensions}
selectedFiles={selectedFiles}
disabled={endpointLoading}
/>

View File

@ -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
});
});
});

View File

@ -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 || '';
};

View File

@ -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. */

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
testTimeout: 5000,
include: ['src/utils/convertUtils.test.ts']
},
})