Readur/frontend/src/components/__tests__/SyncProgressDisplay.test.tsx

594 lines
19 KiB
TypeScript

import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, fireEvent, waitFor, act } from '@testing-library/react';
import SyncProgressDisplay from '../SyncProgressDisplay';
import { renderWithProviders } from '../../test/test-utils';
// Define SyncProgressInfo type locally for tests
interface SyncProgressInfo {
source_id: string;
phase: string;
phase_description: string;
elapsed_time_secs: number;
directories_found: number;
directories_processed: number;
files_found: number;
files_processed: number;
bytes_processed: number;
processing_rate_files_per_sec: number;
files_progress_percent: number;
estimated_time_remaining_secs?: number;
current_directory: string;
current_file?: string;
errors: number;
warnings: number;
is_active: boolean;
}
// Mock the API module using the __mocks__ version
vi.mock('../../services/api');
// Import the mock helpers
import { getMockEventSource, resetMockEventSource } from '../../services/__mocks__/api';
// Create mock progress data factory
const createMockProgressInfo = (overrides: Partial<SyncProgressInfo> = {}): SyncProgressInfo => ({
source_id: 'test-source-123',
phase: 'processing_files',
phase_description: 'Downloading and processing files',
elapsed_time_secs: 120,
directories_found: 10,
directories_processed: 7,
files_found: 50,
files_processed: 30,
bytes_processed: 1024000,
processing_rate_files_per_sec: 2.5,
files_progress_percent: 60.0,
estimated_time_remaining_secs: 80,
current_directory: '/Documents/Projects',
current_file: 'important-document.pdf',
errors: 0,
warnings: 1,
is_active: true,
...overrides,
});
// Helper function to simulate progress updates
const simulateProgressUpdate = (progressData: SyncProgressInfo) => {
const mockEventSource = getMockEventSource();
act(() => {
const progressHandler = mockEventSource.addEventListener.mock.calls.find(
call => call[0] === 'progress'
)?.[1] as (event: MessageEvent) => void;
if (progressHandler) {
progressHandler(new MessageEvent('progress', {
data: JSON.stringify(progressData)
}));
}
});
};
const renderComponent = (props: Partial<React.ComponentProps<typeof SyncProgressDisplay>> = {}) => {
const defaultProps = {
sourceId: 'test-source-123',
sourceName: 'Test WebDAV Source',
isVisible: true,
...props,
};
return renderWithProviders(<SyncProgressDisplay {...defaultProps} />);
};
describe('SyncProgressDisplay Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock EventSource instance
resetMockEventSource();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Visibility and Rendering', () => {
test('should not render when isVisible is false', () => {
renderComponent({ isVisible: false });
expect(screen.queryByText('Test WebDAV Source - Sync Progress')).not.toBeInTheDocument();
});
test('should render when isVisible is true', () => {
renderComponent({ isVisible: true });
expect(screen.getByText('Test WebDAV Source - Sync Progress')).toBeInTheDocument();
});
test('should show connecting status initially', () => {
renderComponent();
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
test('should render with custom source name', () => {
renderComponent({ sourceName: 'My Custom Source' });
expect(screen.getByText('My Custom Source - Sync Progress')).toBeInTheDocument();
});
});
describe('SSE Connection Management', () => {
test('should create EventSource with correct URL', async () => {
renderComponent();
// Since the component creates the stream, we can verify by checking if our mock was called
// The component should call getSyncProgressStream during mount
await waitFor(() => {
// Check that our global EventSource constructor was called with the right URL
expect(global.EventSource).toHaveBeenCalledWith('/api/sources/test-source-123/sync/progress');
});
});
test('should handle successful connection', async () => {
renderComponent();
// Simulate successful connection
const mockEventSource = getMockEventSource();
act(() => {
if (mockEventSource.onopen) {
mockEventSource.onopen(new Event('open'));
}
});
// Should show connected status when there's progress data
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('Live')).toBeInTheDocument();
});
});
test('should handle connection error', async () => {
renderComponent();
const mockEventSource = getMockEventSource();
act(() => {
if (mockEventSource.onerror) {
mockEventSource.onerror(new Event('error'));
}
});
await waitFor(() => {
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
});
test('should close EventSource on unmount', () => {
const { unmount } = renderComponent();
unmount();
expect(getMockEventSource().close).toHaveBeenCalled();
});
test('should close EventSource when visibility changes to false', () => {
const { rerender } = renderComponent({ isVisible: true });
rerender(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test WebDAV Source"
isVisible={false}
/>
);
expect(getMockEventSource().close).toHaveBeenCalled();
});
});
describe('Progress Data Display', () => {
test('should display progress information correctly', async () => {
renderComponent();
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('Downloading and processing files')).toBeInTheDocument();
expect(screen.getByText('30 / 50 files (60.0%)')).toBeInTheDocument();
expect(screen.getByText('7 / 10')).toBeInTheDocument(); // Directories
expect(screen.getByText('1000 KB')).toBeInTheDocument(); // Bytes processed
expect(screen.getByText('2.5 files/sec')).toBeInTheDocument(); // Processing rate
expect(screen.getByText('2m 0s')).toBeInTheDocument(); // Elapsed time
});
});
test('should display current directory and file', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
current_directory: '/Documents/Important',
current_file: 'presentation.pptx',
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('/Documents/Important')).toBeInTheDocument();
expect(screen.getByText('presentation.pptx')).toBeInTheDocument();
});
});
test('should display estimated time remaining', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
estimated_time_remaining_secs: 300, // 5 minutes
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText(/Estimated time remaining: 5m 0s/)).toBeInTheDocument();
});
});
test('should display errors and warnings', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
errors: 3,
warnings: 5,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('3 errors')).toBeInTheDocument();
expect(screen.getByText('5 warnings')).toBeInTheDocument();
});
});
test('should handle singular error/warning labels', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
errors: 1,
warnings: 1,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('1 error')).toBeInTheDocument();
expect(screen.getByText('1 warning')).toBeInTheDocument();
});
});
test('should not show errors/warnings when count is zero', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
errors: 0,
warnings: 0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.queryByText(/error/)).not.toBeInTheDocument();
expect(screen.queryByText(/warning/)).not.toBeInTheDocument();
});
});
test('should not show estimated time when not available', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
estimated_time_remaining_secs: undefined,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.queryByText(/Estimated time remaining/)).not.toBeInTheDocument();
});
});
});
describe('Phase Indicators and Colors', () => {
const testPhases = [
{ phase: 'initializing', color: 'info', description: 'Initializing sync operation' },
{ phase: 'discovering_files', color: 'warning', description: 'Discovering files to sync' },
{ phase: 'processing_files', color: 'primary', description: 'Downloading and processing files' },
{ phase: 'completed', color: 'success', description: 'Sync completed successfully' },
{ phase: 'failed', color: 'error', description: 'Sync failed: Connection timeout' },
];
testPhases.forEach(({ phase, description }) => {
test(`should display correct phase description for ${phase}`, async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
phase,
phase_description: description,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText(description)).toBeInTheDocument();
});
});
});
});
describe('Progress Bar', () => {
test('should show progress bar when files are found', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
files_found: 100,
files_processed: 75,
files_progress_percent: 75.0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveAttribute('aria-valuenow', '75');
});
});
test('should not show progress bar when no files found', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
files_found: 0,
files_processed: 0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
});
describe('Expand/Collapse Functionality', () => {
test('should be expanded by default', () => {
renderComponent();
expect(screen.getByText('Waiting for sync progress information...')).toBeInTheDocument();
});
test('should collapse when collapse button is clicked', async () => {
renderComponent();
const collapseButton = screen.getByLabelText('Collapse');
fireEvent.click(collapseButton);
await waitFor(() => {
// After clicking collapse, the button should change to expand
expect(screen.getByLabelText('Expand')).toBeInTheDocument();
// The content is still in DOM but hidden by Material-UI Collapse
const collapseElement = screen.getByText('Waiting for sync progress information...').closest('.MuiCollapse-root');
expect(collapseElement).toHaveClass('MuiCollapse-hidden');
});
});
test('should expand when expand button is clicked', async () => {
renderComponent();
// First collapse
const collapseButton = screen.getByLabelText('Collapse');
fireEvent.click(collapseButton);
await waitFor(() => {
expect(screen.getByLabelText('Expand')).toBeInTheDocument();
const collapseElement = screen.getByText('Waiting for sync progress information...').closest('.MuiCollapse-root');
expect(collapseElement).toHaveClass('MuiCollapse-hidden');
});
// Then expand
const expandButton = screen.getByLabelText('Expand');
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByLabelText('Collapse')).toBeInTheDocument();
const collapseElement = screen.getByText('Waiting for sync progress information...').closest('.MuiCollapse-root');
expect(collapseElement).toHaveClass('MuiCollapse-entered');
});
});
});
describe('Data Formatting', () => {
test('should format bytes correctly', async () => {
renderComponent();
// Test 1.0 KB case
const mockProgress1KB = createMockProgressInfo({ bytes_processed: 1024 });
simulateProgressUpdate(mockProgress1KB);
await waitFor(() => {
expect(screen.getByText('1 KB')).toBeInTheDocument();
});
});
test('should format zero bytes correctly', async () => {
renderComponent();
// Test 0 B case
const mockProgress0 = createMockProgressInfo({ bytes_processed: 0 });
simulateProgressUpdate(mockProgress0);
await waitFor(() => {
expect(screen.getByText('0 B')).toBeInTheDocument();
});
});
test('should format duration correctly', async () => {
renderComponent();
const testCases = [
{ seconds: 30, expected: '30s' },
{ seconds: 90, expected: '1m 30s' },
{ seconds: 3661, expected: '1h 1m' },
];
for (const { seconds, expected } of testCases) {
const mockProgress = createMockProgressInfo({ elapsed_time_secs: seconds });
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText(expected)).toBeInTheDocument();
});
}
});
});
describe('Heartbeat Handling', () => {
test('should clear progress info on inactive heartbeat', async () => {
renderComponent();
// First set some progress
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('Downloading and processing files')).toBeInTheDocument();
});
// Then send inactive heartbeat
const mockEventSource = getMockEventSource();
act(() => {
const heartbeatHandler = mockEventSource.addEventListener.mock.calls.find(
call => call[0] === 'heartbeat'
)?.[1] as (event: MessageEvent) => void;
if (heartbeatHandler) {
heartbeatHandler(new MessageEvent('heartbeat', {
data: JSON.stringify({
source_id: 'test-source-123',
is_active: false,
timestamp: Date.now()
})
}));
}
});
await waitFor(() => {
expect(screen.getByText('Waiting for sync progress information...')).toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
test('should handle malformed progress data gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent();
const mockEventSource = getMockEventSource();
act(() => {
const progressHandler = mockEventSource.addEventListener.mock.calls.find(
call => call[0] === 'progress'
)?.[1] as (event: MessageEvent) => void;
if (progressHandler) {
progressHandler(new MessageEvent('progress', {
data: 'invalid json'
}));
}
});
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to parse progress event:', expect.any(Error));
});
consoleSpy.mockRestore();
});
test('should handle malformed heartbeat data gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent();
const mockEventSource = getMockEventSource();
act(() => {
const heartbeatHandler = mockEventSource.addEventListener.mock.calls.find(
call => call[0] === 'heartbeat'
)?.[1] as (event: MessageEvent) => void;
if (heartbeatHandler) {
heartbeatHandler(new MessageEvent('heartbeat', {
data: 'invalid json'
}));
}
});
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to parse heartbeat event:', expect.any(Error));
});
consoleSpy.mockRestore();
});
});
describe('Edge Cases', () => {
test('should handle missing current_file gracefully', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
current_directory: '/Documents',
current_file: undefined,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('/Documents')).toBeInTheDocument();
expect(screen.queryByText('Current File')).not.toBeInTheDocument();
});
});
test('should handle zero processing rate', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
processing_rate_files_per_sec: 0.0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('0.0 files/sec')).toBeInTheDocument();
});
});
test('should handle very large numbers', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
bytes_processed: 1099511627776, // 1 TB
files_found: 999999,
files_processed: 500000,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('1 TB')).toBeInTheDocument();
// Check for the large file numbers - they might be split across multiple elements
expect(screen.getByText(/500000/)).toBeInTheDocument();
expect(screen.getByText(/999999/)).toBeInTheDocument();
});
});
});
describe('Accessibility', () => {
test('should have proper ARIA labels', () => {
renderComponent();
expect(screen.getByLabelText('Collapse')).toBeInTheDocument();
});
test('should have accessible progress bar', async () => {
renderComponent();
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toHaveAttribute('aria-valuenow', '60');
expect(progressBar).toHaveAttribute('aria-valuemin', '0');
expect(progressBar).toHaveAttribute('aria-valuemax', '100');
});
});
});
});