import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ResponsePanel } from '../../components/ResponsePanel' import type { SourceMetadata, SubQuestionSources } from '../../types' vi.mock('../../lib/api', () => ({ getChunkPdfUrl: (filePath: string) => `http://localhost:8000/api/v1/chunks/${filePath}/pdf`, getPdfViewerUrl: (filePath: string, page?: number) => { const base = `/pdf-viewer?url=http://localhost:8000/api/v1/chunks/${filePath}/pdf` return page !== undefined ? `${base}&page=${page}` : base }, })) describe('ResponsePanel', () => { const mockSources: SourceMetadata[] = [ { filename: 'document1.pdf', upload_date: '2024-01-15', content_summary: 'Introduction to RAG systems', chunk_index: 0, page_number: 1, chunk_file_path: 'test_chunk_1.pdf', document_id: null, }, { filename: 'document2.txt', upload_date: '2024-01-16', content_summary: 'Advanced retrieval techniques', chunk_index: 1, page_number: null, chunk_file_path: null, document_id: null, }, ] it('shows empty state message when no answer and not loading', () => { render() expect(screen.getByText(/Ask a question to see the answer here/i)).toBeInTheDocument() }) it('shows loading skeletons when isLoading is true', () => { render() const skeletonElements = screen.getAllByTestId('skeleton-line') expect(skeletonElements).toHaveLength(5) expect(skeletonElements[0]).toHaveClass('w-full') expect(skeletonElements[1]).toHaveClass('w-11/12') expect(skeletonElements[2]).toHaveClass('w-4/5') expect(skeletonElements[3]).toHaveClass('w-3/5') expect(skeletonElements[4]).toHaveClass('w-2/5') }) it('shows source skeleton cards during loading', () => { render() const sourceSkeletons = screen.getAllByTestId('source-skeleton') expect(sourceSkeletons).toHaveLength(2) }) it('shows error message when error prop is set', () => { render( ) expect(screen.getByText(/Failed to fetch answer/i)).toBeInTheDocument() }) it('renders answer text as bullet points via markdown', () => { const answer = `- First point\n- Second point\n- Third point\n\nPlain text line` render() expect(screen.getByText('First point')).toBeInTheDocument() expect(screen.getByText('Second point')).toBeInTheDocument() expect(screen.getByText('Third point')).toBeInTheDocument() expect(screen.getByText('Plain text line')).toBeInTheDocument() }) it('renders source metadata cards', () => { render( ) expect(screen.getByText('document1.pdf')).toBeInTheDocument() expect(screen.getByText('document2.txt')).toBeInTheDocument() expect(screen.getByText('2024-01-15')).toBeInTheDocument() expect(screen.getByText('Introduction to RAG systems')).toBeInTheDocument() expect(screen.getByText('Advanced retrieval techniques')).toBeInTheDocument() }) it('does not show sources section when sources array is empty', () => { render() expect(screen.queryByText(/sources/i)).not.toBeInTheDocument() }) it('has a toggle button for sources section', () => { render( ) const toggleButton = screen.getByTestId('sources-toggle') expect(toggleButton).toBeInTheDocument() expect(toggleButton).toHaveTextContent('Sources (2)') }) it('toggles sources visibility when clicking toggle button', () => { render( ) const toggleButton = screen.getByTestId('sources-toggle') const sourcesContainer = screen.getByTestId('sources-container') expect(sourcesContainer).toBeInTheDocument() expect(screen.getByText('document1.pdf')).toBeInTheDocument() fireEvent.click(toggleButton) expect(screen.queryByTestId('sources-container')).not.toBeInTheDocument() expect(toggleButton).toHaveTextContent('Sources (2)') fireEvent.click(toggleButton) expect(screen.getByTestId('sources-container')).toBeInTheDocument() expect(screen.getByText('document1.pdf')).toBeInTheDocument() }) it('has a copy button when answer is shown', () => { render( ) const copyButton = screen.getByTestId('copy-answer-btn') expect(copyButton).toBeInTheDocument() }) it('copies answer to clipboard when copy button is clicked', async () => { const mockClipboardWrite = vi.fn().mockResolvedValue(undefined) Object.assign(navigator, { clipboard: { writeText: mockClipboardWrite, }, }) render( ) const copyButton = screen.getByTestId('copy-answer-btn') fireEvent.click(copyButton) expect(mockClipboardWrite).toHaveBeenCalledWith('Test answer to copy') await waitFor(() => { expect(screen.getByText('Copied!')).toBeInTheDocument() }) }) it('renders inline citations as clickable links', () => { const sourcesWithPdf: SourceMetadata[] = [ { filename: 'NEC4 ACC.pdf', upload_date: '2024-01-15', content_summary: 'Contract terms', chunk_index: 0, page_number: 3, chunk_file_path: 'chunk_0.pdf', document_id: null, }, ] const answer = 'The threshold is HK$1,000,000 [NEC4 ACC.pdf, page 3].' render( ) const citationLink = screen.getByRole('link', { name: '1' }) expect(citationLink).toBeInTheDocument() expect(citationLink).toHaveAttribute('target', '_blank') expect(citationLink).toHaveAttribute('href', expect.stringContaining('/pdf-viewer')) }) it('leaves unmatched citations as plain text', () => { const answer = 'Some info [unknown_file.pdf, page 10] here.' render( ) expect(screen.getByText(/unknown_file\.pdf, page 10/)).toBeInTheDocument() expect(screen.queryByRole('link', { name: /unknown_file/ })).not.toBeInTheDocument() }) describe('SubQuestionSections highlight batch', () => { const mockFetch = vi.fn() const mockSubQuestionSources: SubQuestionSources[] = [ { sub_question_index: 0, sub_question_text: 'What is the threshold?', sources: [ { filename: 'NEC4 ACC.pdf', upload_date: '2024-01-15', content_summary: 'Contract terms', chunk_index: 0, page_number: 3, chunk_file_path: 'chunk_0.pdf', document_id: 'doc-123', }, ], }, ] beforeEach(() => { mockFetch.mockReset() global.fetch = mockFetch }) afterEach(() => { vi.restoreAllMocks() }) it('calls batch highlight endpoint with cited sources', async () => { mockFetch.mockResolvedValue({ json: async () => ({ status: 'completed' }), } as Response) render( ) await waitFor(() => { expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenCalledWith( 'http://localhost:8000/api/v1/v2/highlights/batch', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.stringContaining('doc-123'), }) ) }) }) it('shows View PDF link with normal URL when highlights are not ready', async () => { mockFetch.mockResolvedValue({ json: async () => ({ status: 'pending' }), } as Response) render( ) await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) const toggleButton = screen.getByTestId('sources-toggle') fireEvent.click(toggleButton) const link = screen.getByTestId('view-chunk-pdf-link') expect(link).toHaveAttribute('href', expect.stringContaining('/pdf-viewer')) }) it('upgrades View PDF link to highlight URL when batch completes', async () => { mockFetch.mockResolvedValue({ json: async () => ({ status: 'completed' }), } as Response) render( ) await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) const toggleButton = screen.getByTestId('sources-toggle') fireEvent.click(toggleButton) await waitFor(() => { const link = screen.getByTestId('view-chunk-pdf-link') expect(link).toHaveAttribute('href', expect.stringContaining('/api/v1/v2/highlights')) expect(link).toHaveAttribute('href', expect.stringContaining('doc-123')) }) }) it('does not upgrade link when source has no document_id', async () => { const sourcesWithoutDocId: SubQuestionSources[] = [ { sub_question_index: 0, sub_question_text: 'What is the threshold?', sources: [ { filename: 'NEC4 ACC.pdf', upload_date: '2024-01-15', content_summary: 'Contract terms', chunk_index: 0, page_number: 3, chunk_file_path: 'chunk_0.pdf', document_id: null, }, ], }, ] render( ) const toggleButton = screen.getByTestId('sources-toggle') fireEvent.click(toggleButton) const link = screen.getByTestId('view-chunk-pdf-link') expect(link).toHaveAttribute('href', expect.stringContaining('/pdf-viewer')) }) }) })