test(frontend): add Phase 4 UI and e2e tests for per-sub-q rendering

6 stream state tests for completed, backward compat, generating_subquestion, null sources, lifecycle, and reset. 7 type tests for SubQuestionSources shape, discriminated union narrowing, and QueryResponse. 8 ResponsePanel tests for per-subq sections, source scoping, backward compat flat rendering, copy button, skeletons, and empty states. 7 citationParser tests for per-subq lookup, cross-section isolation, and processCitationsForSubq. 2 e2e tests for per-subq SSE display and progressive generation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Woody 2026-04-26 23:30:08 +08:00
parent 7d072e5ea1
commit 3f292abe1b
5 changed files with 868 additions and 0 deletions

View File

@ -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(
<ResponsePanel
answer={mockAnswer}
sources={[]}
subQuestionSources={mockSubQuestionSources}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer={mockAnswer}
sources={[]}
subQuestionSources={mockSubQuestionSources}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer="Flat answer text"
sources={flatSources}
subQuestionSources={null}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer="Flat answer text"
sources={[]}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer={mockAnswer}
sources={[]}
subQuestionSources={mockSubQuestionSources}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer={null}
sources={[]}
subQuestionSources={null}
isLoading={true}
error={null}
/>
)
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(
<ResponsePanel
answer={answer}
sources={[]}
subQuestionSources={subqWithNoSources}
isLoading={false}
error={null}
/>
)
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(
<ResponsePanel
answer={mockAnswer}
sources={[]}
subQuestionSources={mockSubQuestionSources}
isLoading={false}
error={null}
/>
)
expect(document.getElementById('subq-0')).toBeInTheDocument()
expect(document.getElementById('subq-1')).toBeInTheDocument()
})
})

View File

@ -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<string, unknown>),
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(<App />)
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(<App />)
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()
})
})
})

View File

@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
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')
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})