diff --git a/frontend/src/components/PipelineProgress.tsx b/frontend/src/components/PipelineProgress.tsx new file mode 100644 index 0000000..c5738c3 --- /dev/null +++ b/frontend/src/components/PipelineProgress.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { CheckCircle, Loader2, Circle } from 'lucide-react' + +export interface PipelineProgressProps { + currentStep: number +} + +const STAGES = [ + { label: 'Extracting keywords...', index: 1 }, + { label: 'Retrieving documents...', index: 2 }, + { label: 'Filtering relevance...', index: 3 }, + { label: 'Generating answer...', index: 4 }, +] + +export const PipelineProgress: React.FC = ({ currentStep }) => { + if (currentStep === 0) { + return null + } + + return ( +
+
+ {STAGES.map((stage, index) => { + const stageNumber = index + 1 + const isCompleted = currentStep >= stageNumber && (currentStep === STAGES.length || currentStep > stageNumber) + const isActive = currentStep === stageNumber && currentStep !== STAGES.length + const isPending = currentStep < stageNumber && currentStep !== STAGES.length + + return ( +
+ {index < STAGES.length - 1 && ( +
+ )} + +
+ {isCompleted && ( + + )} + {isActive && ( + + )} + {isPending && ( + + )} +
+ + + {stage.label} + +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/test/components/PipelineProgress.test.tsx b/frontend/src/test/components/PipelineProgress.test.tsx new file mode 100644 index 0000000..cfc56e4 --- /dev/null +++ b/frontend/src/test/components/PipelineProgress.test.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { PipelineProgress } from '../../components/PipelineProgress' + +describe('PipelineProgress', () => { + it('renders nothing when currentStep is 0', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders all 4 stage labels when currentStep >= 1', () => { + render() + + expect(screen.getByText('Extracting keywords...')).toBeInTheDocument() + expect(screen.getByText('Retrieving documents...')).toBeInTheDocument() + expect(screen.getByText('Filtering relevance...')).toBeInTheDocument() + expect(screen.getByText('Generating answer...')).toBeInTheDocument() + }) + + it('marks stages as completed when currentStep > their index', () => { + render() + + expect(screen.getByTestId('stage-1-completed')).toBeInTheDocument() + expect(screen.getByTestId('stage-2-completed')).toBeInTheDocument() + expect(screen.getByTestId('stage-3-active')).toBeInTheDocument() + expect(screen.getByTestId('stage-4-pending')).toBeInTheDocument() + }) + + it('shows active stage with animated indicator when currentStep matches', () => { + render() + + const activeIndicator = screen.getByTestId('stage-2-active') + expect(activeIndicator).toBeInTheDocument() + expect(activeIndicator).toHaveClass('animate-spin') + }) + + it('shows all stages as completed when currentStep is 4', () => { + render() + + expect(screen.getByTestId('stage-1-completed')).toBeInTheDocument() + expect(screen.getByTestId('stage-2-completed')).toBeInTheDocument() + expect(screen.getByTestId('stage-3-completed')).toBeInTheDocument() + expect(screen.getByTestId('stage-4-completed')).toBeInTheDocument() + + expect(screen.queryByTestId(/stage-\d+-active/)).not.toBeInTheDocument() + expect(screen.queryByTestId(/stage-\d+-pending/)).not.toBeInTheDocument() + }) + + it('uses correct stage label text', () => { + render() + + expect(screen.getByText('Extracting keywords...')).toBeInTheDocument() + expect(screen.getByText('Retrieving documents...')).toBeInTheDocument() + expect(screen.getByText('Filtering relevance...')).toBeInTheDocument() + expect(screen.getByText('Generating answer...')).toBeInTheDocument() + }) + + it('shows stage 1 as active when currentStep is 1', () => { + render() + + expect(screen.getByTestId('stage-1-active')).toBeInTheDocument() + expect(screen.getByTestId('stage-1-active')).toHaveClass('animate-spin') + expect(screen.getByTestId('stage-2-pending')).toBeInTheDocument() + expect(screen.getByTestId('stage-3-pending')).toBeInTheDocument() + expect(screen.getByTestId('stage-4-pending')).toBeInTheDocument() + }) + + it('applies correct text colors for each stage state', () => { + render() + + const stage1Label = screen.getByText('Extracting keywords...') + expect(stage1Label).toHaveClass('text-gray-600') + + const stage2Label = screen.getByText('Retrieving documents...') + expect(stage2Label).toHaveClass('text-blue-600') + + const stage3Label = screen.getByText('Filtering relevance...') + expect(stage3Label).toHaveClass('text-gray-400') + + const stage4Label = screen.getByText('Generating answer...') + expect(stage4Label).toHaveClass('text-gray-400') + }) + + it('shows completed stages with CheckCircle icon', () => { + render() + + const completedIcons = screen.getAllByTestId(/stage-\d+-completed/) + completedIcons.forEach((icon) => { + expect(icon).toBeInTheDocument() + }) + }) + + it('shows pending stages with Circle icon', () => { + render() + + const pendingIcons = screen.getAllByTestId(/stage-\d+-pending/) + expect(pendingIcons).toHaveLength(3) + }) + + it('renders main container with correct testid', () => { + render() + + expect(screen.getByTestId('pipeline-progress')).toBeInTheDocument() + }) +})