367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
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(<ResponsePanel answer={null} sources={[]} isLoading={false} error={null} />)
|
|
expect(screen.getByText(/Ask a question to see the answer here/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows loading skeletons when isLoading is true', () => {
|
|
render(<ResponsePanel answer={null} sources={[]} isLoading={true} error={null} />)
|
|
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(<ResponsePanel answer={null} sources={[]} isLoading={true} error={null} />)
|
|
const sourceSkeletons = screen.getAllByTestId('source-skeleton')
|
|
expect(sourceSkeletons).toHaveLength(2)
|
|
})
|
|
|
|
it('shows error message when error prop is set', () => {
|
|
render(
|
|
<ResponsePanel
|
|
answer={null}
|
|
sources={[]}
|
|
isLoading={false}
|
|
error="Failed to fetch answer"
|
|
/>
|
|
)
|
|
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(<ResponsePanel answer={answer} sources={[]} isLoading={false} error={null} />)
|
|
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(
|
|
<ResponsePanel
|
|
answer="Test answer"
|
|
sources={mockSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
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(<ResponsePanel answer="Test answer" sources={[]} isLoading={false} error={null} />)
|
|
expect(screen.queryByText(/sources/i)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('has a toggle button for sources section', () => {
|
|
render(
|
|
<ResponsePanel
|
|
answer="Test answer"
|
|
sources={mockSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
const toggleButton = screen.getByTestId('sources-toggle')
|
|
expect(toggleButton).toBeInTheDocument()
|
|
expect(toggleButton).toHaveTextContent('Sources (2)')
|
|
})
|
|
|
|
it('toggles sources visibility when clicking toggle button', () => {
|
|
render(
|
|
<ResponsePanel
|
|
answer="Test answer"
|
|
sources={mockSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
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(
|
|
<ResponsePanel
|
|
answer="Test answer"
|
|
sources={[]}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
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(
|
|
<ResponsePanel
|
|
answer="Test answer to copy"
|
|
sources={[]}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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(
|
|
<ResponsePanel
|
|
answer={answer}
|
|
sources={sourcesWithPdf}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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(
|
|
<ResponsePanel
|
|
answer={answer}
|
|
sources={mockSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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(
|
|
<ResponsePanel
|
|
answer="The threshold is HK$1,000,000 [NEC4 ACC.pdf, page 3]."
|
|
subQuestionSources={mockSubQuestionSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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(
|
|
<ResponsePanel
|
|
answer="The threshold is HK$1,000,000 [NEC4 ACC.pdf, page 3]."
|
|
subQuestionSources={mockSubQuestionSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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(
|
|
<ResponsePanel
|
|
answer="The threshold is HK$1,000,000 [NEC4 ACC.pdf, page 3]."
|
|
subQuestionSources={mockSubQuestionSources}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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(
|
|
<ResponsePanel
|
|
answer="The threshold is HK$1,000,000 [NEC4 ACC.pdf, page 3]."
|
|
subQuestionSources={sourcesWithoutDocId}
|
|
isLoading={false}
|
|
error={null}
|
|
/>
|
|
)
|
|
|
|
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'))
|
|
})
|
|
})
|
|
})
|