diff --git a/frontend/src/test/components/test_phase4_response_panel.test.tsx b/frontend/src/test/components/test_phase4_response_panel.test.tsx new file mode 100644 index 0000000..0704c6e --- /dev/null +++ b/frontend/src/test/components/test_phase4_response_panel.test.tsx @@ -0,0 +1,214 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ResponsePanel } from '../../components/ResponsePanel' +import type { SubQuestionSources, SourceMetadata } 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 + }, +})) + +const mockSubQuestionSources: SubQuestionSources[] = [ + { + sub_question_index: 0, + sub_question_text: 'What are time extensions?', + sources: [ + { + filename: 'time_ext.pdf', + upload_date: '2026-04-23', + content_summary: 'Clause 61.3...', + chunk_index: 0, + page_number: 3, + chunk_file_path: 'chunk_0.pdf', + }, + ], + }, + { + sub_question_index: 1, + sub_question_text: 'What notice is required?', + sources: [ + { + filename: 'notice_req.pdf', + upload_date: '2026-04-23', + content_summary: 'Notice must...', + chunk_index: 1, + page_number: 7, + chunk_file_path: 'chunk_1.pdf', + }, + ], + }, +] + +const mockAnswer = `## Sub-question 1: What are time extensions? +- Time extensions must be notified [time_ext.pdf, page 3] + +## Sub-question 2: What notice is required? +- Written notice must be given [notice_req.pdf, page 7]` + +describe('ResponsePanel — per-sub-question rendering (Phase 4)', () => { + it('renders per-subq sections when subQuestionSources provided', () => { + render( + + ) + + expect(screen.getByText('What are time extensions?')).toBeInTheDocument() + expect(screen.getByText('What notice is required?')).toBeInTheDocument() + expect(screen.getByText('Time extensions must be notified')).toBeInTheDocument() + expect(screen.getByText('Written notice must be given')).toBeInTheDocument() + }) + + it('each section has its own sources', () => { + render( + + ) + + const toggles = screen.getAllByTestId('sources-toggle') + expect(toggles).toHaveLength(2) + expect(toggles[0]).toHaveTextContent('Sources (1)') + expect(screen.getAllByTestId('sources-container')).toHaveLength(2) + + fireEvent.click(toggles[1]) + const sourceCards = screen.getAllByTestId('sources-container') + expect(sourceCards).toHaveLength(1) + expect(screen.getByText(/Page 3/)).toBeInTheDocument() + expect(screen.queryByText(/Page 7/)).not.toBeInTheDocument() + }) + + it('falls back to flat rendering when subQuestionSources is null', () => { + const flatSources: SourceMetadata[] = [ + { + filename: 'doc.pdf', + upload_date: '2026-04-23', + content_summary: 'Summary', + chunk_index: 0, + page_number: 1, + chunk_file_path: 'chunk.pdf', + }, + ] + + render( + + ) + + expect(screen.getByText('Flat answer text')).toBeInTheDocument() + expect(screen.getByText('doc.pdf')).toBeInTheDocument() + expect(screen.queryByText('What are time extensions?')).not.toBeInTheDocument() + }) + + it('falls back to flat rendering when subQuestionSources is undefined', () => { + render( + + ) + + expect(screen.getByText('Flat answer text')).toBeInTheDocument() + expect(screen.queryByText('What are time extensions?')).not.toBeInTheDocument() + }) + + it('copy button copies full answer including all sub-question headers', 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(mockAnswer) + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument() + }) + }) + + it('shows skeleton loaders when isLoading is true', () => { + render( + + ) + + const skeletonElements = screen.getAllByTestId('skeleton-line') + expect(skeletonElements.length).toBeGreaterThan(0) + }) + + it('shows no-relevant-information message for sub-question with empty sources', () => { + const subqWithNoSources: SubQuestionSources[] = [ + { + sub_question_index: 0, + sub_question_text: 'What are time extensions?', + sources: [], + }, + ] + + const answer = '## Sub-question 1: What are time extensions?\n- No relevant information found.' + + render( + + ) + + expect(screen.getByText('What are time extensions?')).toBeInTheDocument() + expect(screen.queryByTestId('sources-toggle')).not.toBeInTheDocument() + }) + + it('adds subq-{index} id to each section container', () => { + render( + + ) + + expect(document.getElementById('subq-0')).toBeInTheDocument() + expect(document.getElementById('subq-1')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/e2e/test_phase4_e2e_query_flow.test.tsx b/frontend/src/test/e2e/test_phase4_e2e_query_flow.test.tsx new file mode 100644 index 0000000..41f1b98 --- /dev/null +++ b/frontend/src/test/e2e/test_phase4_e2e_query_flow.test.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { queryClient } from '../../lib/queries' +import App from '../../App' +import type { SubQuestionSources } from '../../types' + +vi.mock('../../lib/api', async () => { + const actual = await vi.importActual('../../lib/api') + return { + ...(actual as Record), + queryDocumentStream: vi.fn(), + ingestDocument: vi.fn(), + } +}) + +import { queryDocumentStream, ingestDocument } from '../../lib/api' + +const mockQueryDocumentStream = vi.mocked(queryDocumentStream) +const mockIngestDocument = vi.mocked(ingestDocument) + +const mockSubQuestionSources: SubQuestionSources[] = [ + { + sub_question_index: 0, + sub_question_text: 'Who created Python?', + sources: [ + { + filename: 'python.pdf', + upload_date: '2024-01-15', + content_summary: 'Python origins', + chunk_index: 0, + page_number: 1, + chunk_file_path: null, + }, + ], + }, + { + sub_question_index: 1, + sub_question_text: 'What paradigms does Python support?', + sources: [ + { + filename: 'python.pdf', + upload_date: '2024-01-15', + content_summary: 'Python paradigms', + chunk_index: 1, + page_number: 2, + chunk_file_path: null, + }, + ], + }, +] + +const mockCompletedAnswer = [ + '## Sub-question 1: Who created Python?', + '- Python was created by Guido van Rossum [python.pdf, page 1]', + '', + '## Sub-question 2: What paradigms does Python support?', + '- Python supports procedural and object-oriented paradigms [python.pdf, page 2]', +].join('\n') + +describe('Phase 4 per-sub-question query flow', () => { + beforeEach(() => { + queryClient.clear() + vi.restoreAllMocks() + mockIngestDocument.mockResolvedValue({ + document_id: 'doc-abc', + chunk_count: 3, + filename: 'test.pdf', + }) + }) + + it('displays per-sub-question sections with sources from SSE stream', async () => { + mockQueryDocumentStream.mockImplementation(async (_request, onEvent) => { + onEvent({ phase: 'decomposed', extracted_questions: ['Who created Python?', 'What paradigms?'] }) + onEvent({ phase: 'retrieving' }) + onEvent({ phase: 'filtering' }) + onEvent({ phase: 'generating' }) + onEvent({ phase: 'generating_subquestion', sub_question_index: 0, sub_question_text: 'Who created Python?' }) + onEvent({ phase: 'generating_subquestion', sub_question_index: 1, sub_question_text: 'What paradigms?' }) + onEvent({ + phase: 'completed', + answer: mockCompletedAnswer, + sub_question_sources: mockSubQuestionSources, + sources: mockSubQuestionSources.flatMap(sq => sq.sources), + }) + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask a question about your documents...') + fireEvent.change(textarea, { target: { value: 'Who created Python and what paradigms?' } }) + + const submitButton = screen.getByRole('button', { name: /submit/i }) + await act(async () => { + fireEvent.click(submitButton) + }) + + await waitFor(() => { + expect(screen.getAllByText('Who created Python?').length).toBeGreaterThanOrEqual(1) + }) + expect(screen.getAllByText(/What paradigms/).length).toBeGreaterThanOrEqual(1) + + await waitFor(() => { + expect(screen.getByText(/Guido van Rossum/)).toBeInTheDocument() + }) + expect(screen.getByText(/procedural and object-oriented/)).toBeInTheDocument() + }) + + it('shows progressive generation state via generating_subquestion events', async () => { + let onEventRef: ((event: any) => void) | null = null + + mockQueryDocumentStream.mockImplementation(async (_request, onEvent) => { + onEventRef = onEvent + onEvent({ phase: 'decomposed', extracted_questions: ['Question A'] }) + onEvent({ phase: 'retrieving' }) + onEvent({ phase: 'filtering' }) + onEvent({ phase: 'generating' }) + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask a question about your documents...') + fireEvent.change(textarea, { target: { value: 'Test progressive' } }) + + const submitButton = screen.getByRole('button', { name: /submit/i }) + await act(async () => { + fireEvent.click(submitButton) + }) + + await waitFor(() => { + expect(screen.getByText('Question A')).toBeInTheDocument() + }) + + expect(onEventRef).toBeTruthy() + if (onEventRef) { + onEventRef({ phase: 'generating_subquestion', sub_question_index: 0, sub_question_text: 'Question A' }) + } + + await act(async () => { + if (onEventRef) { + onEventRef({ + phase: 'completed', + answer: '## Sub-question 1: Question A\n- Answer here', + sub_question_sources: mockSubQuestionSources.slice(0, 1), + sources: mockSubQuestionSources[0].sources, + }) + } + }) + + await waitFor(() => { + expect(screen.getByText(/Answer here/)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/test/lib/test_phase4_stream_state.test.tsx b/frontend/src/test/lib/test_phase4_stream_state.test.tsx new file mode 100644 index 0000000..e318308 --- /dev/null +++ b/frontend/src/test/lib/test_phase4_stream_state.test.tsx @@ -0,0 +1,288 @@ +import React from 'react' +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useQueryDocumentStream } from '../../lib/queries' +import type { QueryStreamEvent, SubQuestionSources, SourceMetadata } from '../../types' + +/** + * Tests for Phase 4.5: SSE stream state handling for per-sub-question response format. + * + * Covers: + * - completed event with sub_question_sources populates state + * - backward-compatible flat sources array still accessible + * - generating_subquestion event updates phase tracking + * - full stream lifecycle with all new event shapes + */ + +vi.mock('../../lib/api', () => ({ + queryDocumentStream: vi.fn(), +})) + +import { queryDocumentStream } from '../../lib/api' + +const mockQueryDocumentStream = vi.mocked(queryDocumentStream) + +function createWrapper() { + const queryClient = new QueryClient() + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +const sampleSources: SourceMetadata[] = [ + { + filename: 'doc1.pdf', + upload_date: '2024-01-15', + content_summary: 'Summary of doc1', + chunk_index: 0, + page_number: 1, + chunk_file_path: 'chunk_0.pdf', + }, +] + +const sampleSubQuestionSources: SubQuestionSources[] = [ + { + sub_question_index: 0, + sub_question_text: 'What is RAG?', + sources: sampleSources, + }, + { + sub_question_index: 1, + sub_question_text: 'How does retrieval work?', + sources: [], + }, +] + +describe('Phase 4.5 Stream State', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('test_completed_event_sets_sub_question_sources', async () => { + let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined + mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => { + capturedOnEvent = onEvent + }) + + const { result } = renderHook(() => useQueryDocumentStream(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.mutate({ question: 'test question' }) + }) + + await act(async () => { + capturedOnEvent!({ + phase: 'completed', + answer: '## Sub-question 1: What is RAG?\n- RAG is...', + sub_question_sources: sampleSubQuestionSources, + sources: sampleSources, + }) + }) + + expect(result.current.subQuestionSources).toEqual(sampleSubQuestionSources) + expect(result.current.subQuestionSources).toHaveLength(2) + expect(result.current.subQuestionSources![0].sub_question_text).toBe('What is RAG?') + expect(result.current.subQuestionSources![1].sub_question_text).toBe('How does retrieval work?') + }) + + it('test_completed_event_keeps_backward_compat_sources', async () => { + let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined + mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => { + capturedOnEvent = onEvent + }) + + const { result } = renderHook(() => useQueryDocumentStream(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.mutate({ question: 'test question' }) + }) + + await act(async () => { + capturedOnEvent!({ + phase: 'completed', + answer: 'Test answer', + sub_question_sources: sampleSubQuestionSources, + sources: sampleSources, + }) + }) + + expect(result.current.sources).toEqual(sampleSources) + expect(result.current.sources).toHaveLength(1) + expect(result.current.sources![0].filename).toBe('doc1.pdf') + }) + + it('test_generating_subquestion_event_updates_phase', async () => { + let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined + mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => { + capturedOnEvent = onEvent + }) + + const { result } = renderHook(() => useQueryDocumentStream(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.mutate({ question: 'test question' }) + }) + + await act(async () => { + capturedOnEvent!({ phase: 'generating' }) + }) + expect(result.current.phase).toBe('generating') + + await act(async () => { + capturedOnEvent!({ + phase: 'generating_subquestion', + sub_question_index: 0, + sub_question_text: 'What is RAG?', + }) + }) + expect(result.current.phase).toBe('generating') + + await act(async () => { + capturedOnEvent!({ + phase: 'generating_subquestion', + sub_question_index: 1, + sub_question_text: 'How does retrieval work?', + }) + }) + expect(result.current.phase).toBe('generating') + }) + + it('test_completed_event_without_sub_question_sources_sets_null', async () => { + let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined + mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => { + capturedOnEvent = onEvent + }) + + const { result } = renderHook(() => useQueryDocumentStream(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.mutate({ question: 'test question' }) + }) + + await act(async () => { + capturedOnEvent!({ + phase: 'completed', + answer: 'Simple answer', + sources: sampleSources, + }) + }) + + expect(result.current.subQuestionSources).toBeNull() + expect(result.current.answer).toBe('Simple answer') + expect(result.current.sources).toEqual(sampleSources) + }) + + it('test_full_stream_lifecycle_with_sub_questions', async () => { + let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined + mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => { + capturedOnEvent = onEvent + }) + + const { result } = renderHook(() => useQueryDocumentStream(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.mutate({ question: 'complex question' }) + }) + + await act(async () => { + capturedOnEvent!({ + phase: 'decomposed', + extracted_questions: ['q1', 'q2'], + }) + }) + expect(result.current.phase).toBe('retrieving') + expect(result.current.extractedQuestions).toEqual(['q1', 'q2']) + + await act(async () => { + capturedOnEvent!({ phase: 'retrieving' }) + }) + expect(result.current.phase).toBe('retrieving') + + await act(async () => { + capturedOnEvent!({ phase: 'filtering' }) + }) + expect(result.current.phase).toBe('filtering') + + await act(async () => { + capturedOnEvent!({ phase: 'generating' }) + }) + expect(result.current.phase).toBe('generating') + + await act(async () => { + capturedOnEvent!({ + phase: 'generating_subquestion', + sub_question_index: 0, + sub_question_text: 'q1', + }) + }) + expect(result.current.phase).toBe('generating') + + await act(async () => { + capturedOnEvent!({ + phase: 'generating_subquestion', + sub_question_index: 1, + sub_question_text: 'q2', + }) + }) + expect(result.current.phase).toBe('generating') + + await act(async () => { + capturedOnEvent!({ + phase: 'completed', + answer: '## Sub-question 1: q1\n- answer1\n\n## Sub-question 2: q2\n- answer2', + sub_question_sources: sampleSubQuestionSources, + sources: sampleSources, + }) + }) + expect(result.current.phase).toBe('completed') + expect(result.current.answer).toContain('Sub-question 1: q1') + expect(result.current.subQuestionSources).toEqual(sampleSubQuestionSources) + expect(result.current.sources).toEqual(sampleSources) + }) + + it('test_reset_clears_sub_question_sources', async () => { + let capturedOnEvent: ((event: QueryStreamEvent) => void) | undefined + mockQueryDocumentStream.mockImplementation(async (_req, onEvent) => { + capturedOnEvent = onEvent + }) + + const { result } = renderHook(() => useQueryDocumentStream(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.mutate({ question: 'test question' }) + }) + + await act(async () => { + capturedOnEvent!({ + phase: 'completed', + answer: 'answer', + sub_question_sources: sampleSubQuestionSources, + sources: sampleSources, + }) + }) + + expect(result.current.subQuestionSources).toEqual(sampleSubQuestionSources) + + await act(async () => { + result.current.reset() + }) + + expect(result.current.subQuestionSources).toBeNull() + expect(result.current.answer).toBeNull() + expect(result.current.sources).toBeNull() + expect(result.current.phase).toBe('idle') + }) +}) diff --git a/frontend/src/test/lib/test_phase4_types.test.ts b/frontend/src/test/lib/test_phase4_types.test.ts new file mode 100644 index 0000000..b0f984f --- /dev/null +++ b/frontend/src/test/lib/test_phase4_types.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import type { + SubQuestionSources, + QueryStreamEvent, + SourceMetadata, + QueryResponse, +} from '../../types' + +/** + * Tests for Phase 4.5: TypeScript type definitions for per-sub-question response format. + * + * Covers: + * - SubQuestionSources interface shape + * - QueryStreamEvent discriminated union on `phase` field + * - QueryResponse includes sub_question_sources + * - Completed event shape narrows correctly via discriminated union + */ + +const sampleSource: SourceMetadata = { + filename: 'test.pdf', + upload_date: '2024-01-01', + content_summary: 'Test summary', + chunk_index: 0, + page_number: 1, + chunk_file_path: 'chunk_0.pdf', +} + +describe('Phase 4.5 Types', () => { + it('test_sub_question_sources_type', () => { + const sqs: SubQuestionSources = { + sub_question_index: 0, + sub_question_text: 'What is RAG?', + sources: [sampleSource], + } + + expect(sqs.sub_question_index).toBe(0) + expect(sqs.sub_question_text).toBe('What is RAG?') + expect(sqs.sources).toHaveLength(1) + expect(sqs.sources[0].filename).toBe('test.pdf') + }) + + it('test_sub_question_sources_with_empty_sources', () => { + const sqs: SubQuestionSources = { + sub_question_index: 1, + sub_question_text: 'No sources question', + sources: [], + } + + expect(sqs.sources).toHaveLength(0) + }) + + it('test_query_stream_event_discriminated_union_completed', () => { + const event: QueryStreamEvent = { + phase: 'completed', + answer: '## Sub-question 1: q1\n- a1', + sub_question_sources: [ + { sub_question_index: 0, sub_question_text: 'q1', sources: [sampleSource] }, + ], + sources: [sampleSource], + } + + if (event.phase === 'completed') { + expect(event.answer).toBeDefined() + expect(event.sub_question_sources).toHaveLength(1) + expect(event.sub_question_sources[0].sub_question_text).toBe('q1') + } + }) + + it('test_query_stream_event_discriminated_union_generating_subquestion', () => { + const event: QueryStreamEvent = { + phase: 'generating_subquestion', + sub_question_index: 0, + sub_question_text: 'What is RAG?', + } + + if (event.phase === 'generating_subquestion') { + expect(event.sub_question_index).toBe(0) + expect(event.sub_question_text).toBe('What is RAG?') + } + }) + + it('test_query_stream_event_discriminated_union_decomposed', () => { + const event: QueryStreamEvent = { + phase: 'decomposed', + extracted_questions: ['q1', 'q2'], + } + + if (event.phase === 'decomposed') { + expect(event.extracted_questions).toEqual(['q1', 'q2']) + } + }) + + it('test_query_stream_event_discriminated_union_simple_phases', () => { + const retrieving: QueryStreamEvent = { phase: 'retrieving' } + const filtering: QueryStreamEvent = { phase: 'filtering' } + const generating: QueryStreamEvent = { phase: 'generating' } + const error: QueryStreamEvent = { phase: 'error', message: 'fail' } + + expect(retrieving.phase).toBe('retrieving') + expect(filtering.phase).toBe('filtering') + expect(generating.phase).toBe('generating') + if (error.phase === 'error') { + expect(error.message).toBe('fail') + } + }) + + it('test_query_response_includes_sub_question_sources', () => { + const response: QueryResponse = { + extracted_questions: ['q1', 'q2'], + answer: 'Answer text', + sub_question_sources: [ + { sub_question_index: 0, sub_question_text: 'q1', sources: [sampleSource] }, + { sub_question_index: 1, sub_question_text: 'q2', sources: [] }, + ], + sources: [sampleSource], + } + + expect(response.sub_question_sources).toHaveLength(2) + expect(response.sub_question_sources[0].sub_question_text).toBe('q1') + expect(response.sources).toHaveLength(1) + }) +}) diff --git a/frontend/src/test/utils/test_phase4_citation_parser.test.ts b/frontend/src/test/utils/test_phase4_citation_parser.test.ts new file mode 100644 index 0000000..f47131e --- /dev/null +++ b/frontend/src/test/utils/test_phase4_citation_parser.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { buildCitationLookupForSubq, processCitationsForSubq } from '../../utils/citationParser' +import type { SubQuestionSources } from '../../types' + +const mockSubQuestionSources: SubQuestionSources[] = [ + { + sub_question_index: 0, + sub_question_text: 'What are time extensions?', + sources: [ + { + filename: 'time_ext.pdf', + upload_date: '2026-04-23', + content_summary: 'Clause 61.3...', + chunk_index: 0, + page_number: 3, + chunk_file_path: 'chunk_0.pdf', + }, + ], + }, + { + sub_question_index: 1, + sub_question_text: 'What notice is required?', + sources: [ + { + filename: 'notice_req.pdf', + upload_date: '2026-04-23', + content_summary: 'Notice must...', + chunk_index: 1, + page_number: 7, + chunk_file_path: 'chunk_1.pdf', + }, + ], + }, +] + +describe('buildCitationLookupForSubq', () => { + it('returns lookup scoped to specified sub-question only', () => { + const lookup = buildCitationLookupForSubq(mockSubQuestionSources, 0) + expect(lookup.has('time_ext.pdf, page 3')).toBe(true) + expect(lookup.has('notice_req.pdf, page 7')).toBe(false) + }) + + it('returns empty lookup for out-of-range subq index', () => { + const lookup = buildCitationLookupForSubq(mockSubQuestionSources, 99) + expect(lookup.size).toBe(0) + }) + + it('does not leak sources from other sub-questions', () => { + const lookup0 = buildCitationLookupForSubq(mockSubQuestionSources, 0) + const lookup1 = buildCitationLookupForSubq(mockSubQuestionSources, 1) + + expect(lookup0.has('notice_req.pdf, page 7')).toBe(false) + expect(lookup1.has('time_ext.pdf, page 3')).toBe(false) + }) +}) + +describe('processCitationsForSubq', () => { + it('replaces citation when it exists in the sub-question sources', () => { + const text = 'Time extensions [time_ext.pdf, page 3] are important.' + const result = processCitationsForSubq(text, mockSubQuestionSources, 0) + expect(result).toContain('](') + expect(result).toContain('/pdf-viewer') + }) + + it('leaves citation plain when it belongs to a different sub-question', () => { + const text = 'Notice [notice_req.pdf, page 7] is required.' + const result = processCitationsForSubq(text, mockSubQuestionSources, 0) + expect(result).toBe(text) + expect(result).not.toContain('/pdf-viewer') + }) + + it('correctly processes each sub-question independently', () => { + const text0 = 'Clause [time_ext.pdf, page 3] applies.' + const text1 = 'Notice [notice_req.pdf, page 7] applies.' + + const result0 = processCitationsForSubq(text0, mockSubQuestionSources, 0) + const result1 = processCitationsForSubq(text1, mockSubQuestionSources, 1) + + expect(result0).toContain('/pdf-viewer') + expect(result0).not.toBe(text0) + + expect(result1).toContain('/pdf-viewer') + expect(result1).not.toBe(text1) + }) + + it('returns original text when subq index is out of range', () => { + const text = 'Citation [time_ext.pdf, page 3] here.' + const result = processCitationsForSubq(text, mockSubQuestionSources, 99) + expect(result).toBe(text) + }) +})