Files
flyer-crawler.projectium.com/src/features/flyer/ProcessingStatus.test.tsx
Torben Sorensen a42ee5a461
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s
unit tests - wheeee! Claude is the mvp
2026-01-09 21:59:09 -08:00

270 lines
11 KiB
TypeScript

// src/features/flyer/ProcessingStatus.test.tsx
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ProcessingStatus } from './ProcessingStatus';
import type { ProcessingStage } from '../../types';
import { createMockProcessingStage } from '../../tests/utils/mockFactories';
describe('ProcessingStatus', () => {
const mockStages: ProcessingStage[] = [
createMockProcessingStage({ name: 'Uploading File', status: 'completed', detail: 'Done' }),
createMockProcessingStage({
name: 'Converting to Image',
status: 'in-progress',
detail: 'Page 2 of 5...',
}),
createMockProcessingStage({ name: 'Extracting Text', status: 'pending', detail: '' }),
createMockProcessingStage({
name: 'Analyzing with AI',
status: 'error',
detail: 'AI model timeout',
critical: false,
}),
createMockProcessingStage({
name: 'Saving to Database',
status: 'error',
detail: 'Connection failed',
critical: true,
}),
];
describe('Single File Layout', () => {
it('should render the title and initial time remaining', () => {
render(<ProcessingStatus stages={[]} estimatedTime={125} />);
expect(screen.getByRole('heading', { name: /processing your flyer/i })).toBeInTheDocument();
expect(screen.getByText(/estimated time remaining: 2m 5s/i)).toBeInTheDocument();
});
describe('with fake timers', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should count down the time remaining', () => {
render(<ProcessingStatus stages={[]} estimatedTime={125} />);
expect(screen.getByText(/estimated time remaining: 2m 5s/i)).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(3000); // Advance time by 3 seconds
});
expect(screen.getByText(/estimated time remaining: 2m 2s/i)).toBeInTheDocument();
});
it('should stop the countdown at 0', () => {
render(<ProcessingStatus stages={[]} estimatedTime={2} />);
expect(screen.getByText(/estimated time remaining: 0m 2s/i)).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(5000); // Advance time by more than the remaining time
});
expect(screen.getByText(/estimated time remaining: 0m 0s/i)).toBeInTheDocument();
});
});
it('should render all stages with correct statuses and icons', () => {
render(<ProcessingStatus stages={mockStages} estimatedTime={120} />);
// Completed stage
const completedStageText = screen.getByTestId('stage-text-0');
expect(completedStageText.className).toContain('text-gray-700');
expect(completedStageText).toHaveTextContent('Uploading File');
expect(screen.getByTestId('stage-icon-0').querySelector('svg')).toHaveClass('text-green-500');
// In-progress stage`
const inProgressStageText = screen.getByTestId('stage-text-1');
expect(inProgressStageText.className).toContain('text-brand-primary');
expect(screen.getByTestId('stage-icon-1').querySelector('svg')).toHaveClass('animate-spin');
// Pending stage
const pendingStageText = screen.getByTestId('stage-text-2');
expect(pendingStageText.className).toContain('text-gray-400');
expect(screen.getByTestId('stage-icon-2').querySelector('div')).toHaveClass(
'border-gray-400',
);
// Non-critical error stage
const nonCriticalErrorStageText = screen.getByTestId('stage-text-3');
expect(nonCriticalErrorStageText.className).toContain('text-yellow-600');
expect(screen.getByTestId('stage-icon-3').querySelector('svg')).toHaveClass(
'text-yellow-500',
);
expect(screen.getByText(/optional/i)).toBeInTheDocument();
// Critical error stage
const criticalErrorStageText = screen.getByTestId('stage-text-4');
expect(criticalErrorStageText.className).toContain('text-red-500');
expect(screen.getByTestId('stage-icon-4').querySelector('svg')).toHaveClass('text-red-500');
});
it('should render PDF conversion progress bar', () => {
render(
<ProcessingStatus
stages={[]}
estimatedTime={60}
pageProgress={{ current: 3, total: 10 }}
/>,
);
const progressBar = screen.getByText(/converting pdf: page 3 of 10/i).nextElementSibling
?.firstChild;
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 30%');
});
it('should render item extraction progress bar for a stage', () => {
const stagesWithProgress: ProcessingStage[] = [
createMockProcessingStage({
name: 'Extracting Items',
status: 'in-progress',
progress: { current: 4, total: 8 },
}),
];
render(<ProcessingStatus stages={stagesWithProgress} estimatedTime={60} />);
const progressBar = screen.getByText(/analyzing page 4 of 8/i).nextElementSibling?.firstChild;
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 50%');
});
});
describe('Bulk Processing Layout', () => {
const bulkProps = {
stages: mockStages,
estimatedTime: 300,
currentFile: 'flyer_batch_01.pdf',
bulkProgress: 25,
bulkFileCount: { current: 2, total: 8 },
};
it('should render the bulk processing layout with current file name', () => {
render(<ProcessingStatus {...bulkProps} />);
// The heading now includes the filename, so we can check for it in one assertion.
const heading = screen.getByRole('heading', { name: /Processing: flyer_batch_01.pdf/i });
expect(heading).toBeInTheDocument();
});
it('should render the overall bulk progress bar', () => {
render(<ProcessingStatus {...bulkProps} />);
const progressBar = screen.getByText(/file 2 of 8/i).nextElementSibling?.firstChild;
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 25%');
});
it('should render the PDF conversion progress bar in bulk mode', () => {
render(<ProcessingStatus {...bulkProps} pageProgress={{ current: 1, total: 5 }} />);
const progressBar = screen.getByText(/converting pdf: page 1 of 5/i).nextElementSibling
?.firstChild;
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 20%');
});
it('should render the item extraction progress bar from the correct stage in bulk mode', () => {
const stagesWithProgress: ProcessingStage[] = [
createMockProcessingStage({ name: 'Some Other Step', status: 'completed' }),
createMockProcessingStage({
name: 'Extracting All Items from Flyer',
status: 'in-progress',
progress: { current: 3, total: 10 },
}),
];
render(<ProcessingStatus {...bulkProps} stages={stagesWithProgress} />);
const progressBar =
screen.getByText(/analyzing page 3 of 10/i).nextElementSibling?.firstChild;
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveStyle('width: 30%');
});
it('should render the checklist of stages on the right', () => {
render(<ProcessingStatus {...bulkProps} />);
// Find the list of stages by its role. This is more robust than relying on specific CSS classes.
const stageList = screen.getByRole('list');
expect(stageList).toBeInTheDocument();
expect(stageList).toHaveTextContent('Uploading File');
expect(stageList).toHaveTextContent('Converting to Image');
});
});
describe('Conditional Rendering', () => {
it('should not render any progress bars if props are not provided', () => {
render(<ProcessingStatus stages={mockStages} estimatedTime={60} />);
expect(screen.queryByText(/converting pdf/i)).not.toBeInTheDocument();
expect(screen.queryByText(/overall progress/i)).not.toBeInTheDocument();
expect(screen.queryByText(/analyzing page/i)).not.toBeInTheDocument();
});
it('should render stage details and optional text', () => {
render(<ProcessingStatus stages={mockStages} estimatedTime={120} />);
// Stage with detail
const inProgressStage = screen.getByTestId('stage-text-1');
expect(inProgressStage).toHaveTextContent('Page 2 of 5...');
// Stage with non-critical error and optional text
const nonCriticalErrorStage = screen.getByTestId('stage-text-3');
expect(nonCriticalErrorStage).toHaveTextContent('AI model timeout');
expect(nonCriticalErrorStage).toHaveTextContent('(optional)');
});
});
describe('Edge Cases', () => {
it('should render null for unknown stage status icon', () => {
const stagesWithUnknownStatus: ProcessingStage[] = [
createMockProcessingStage({
name: 'Unknown Stage',
status: 'unknown-status' as any,
detail: '',
}),
];
render(<ProcessingStatus stages={stagesWithUnknownStatus} estimatedTime={60} />);
const stageIcon = screen.getByTestId('stage-icon-0');
// The icon container should be empty (no SVG or spinner rendered)
expect(stageIcon.querySelector('svg')).not.toBeInTheDocument();
expect(stageIcon.querySelector('.animate-spin')).not.toBeInTheDocument();
});
it('should return empty string for unknown stage status text color', () => {
const stagesWithUnknownStatus: ProcessingStage[] = [
createMockProcessingStage({
name: 'Unknown Stage',
status: 'unknown-status' as any,
detail: '',
}),
];
render(<ProcessingStatus stages={stagesWithUnknownStatus} estimatedTime={60} />);
const stageText = screen.getByTestId('stage-text-0');
// Should not have any of the known status color classes
expect(stageText.className).not.toContain('text-brand-primary');
expect(stageText.className).not.toContain('text-gray-700');
expect(stageText.className).not.toContain('text-gray-400');
expect(stageText.className).not.toContain('text-red-500');
expect(stageText.className).not.toContain('text-yellow-600');
});
it('should not render page progress bar when total is 1', () => {
render(
<ProcessingStatus stages={[]} estimatedTime={60} pageProgress={{ current: 1, total: 1 }} />,
);
expect(screen.queryByText(/converting pdf/i)).not.toBeInTheDocument();
});
it('should not render stage progress bar when total is 1', () => {
const stagesWithSinglePageProgress: ProcessingStage[] = [
createMockProcessingStage({
name: 'Extracting Items',
status: 'in-progress',
progress: { current: 1, total: 1 },
}),
];
render(<ProcessingStatus stages={stagesWithSinglePageProgress} estimatedTime={60} />);
expect(screen.queryByText(/analyzing page/i)).not.toBeInTheDocument();
});
});
});