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:
parent
7d072e5ea1
commit
3f292abe1b
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue