mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 10:35:22 +00:00
Stuff
This commit is contained in:
parent
8dff995a1c
commit
32825acc9b
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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('');
|
||||
});
|
||||
|
||||
|
@ -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 => ({
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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 })
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
|
@ -144,6 +144,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
parameters={convertParams.parameters}
|
||||
onParameterChange={convertParams.updateParameter}
|
||||
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
||||
selectedFiles={selectedFiles}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
|
||||
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
@ -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 || '';
|
||||
};
|
||||
|
||||
|
@ -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. */
|
||||
|
9
frontend/vitest.minimal.config.ts
Normal file
9
frontend/vitest.minimal.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
testTimeout: 5000,
|
||||
include: ['src/utils/convertUtils.test.ts']
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user