docling-studio/frontend/src/features/reasoning/store.test.ts
Pier-Jean Malandrino 1f02274ac4 feat(reasoning): reasoning-trace viewer v1 with SQLite-backed graph
Adds the `docling-agent` reasoning-trace viewer as a Studio tunnel, per
`docs/design/reasoning-trace.md`. Users pick an analyzed document, import
a RAGResult JSON, and the iterations are overlaid on the document graph.

Graph source is decoupled from Neo4j: a new pure builder
(`infra/docling_graph.build_graph_payload`) reads `document_json` from
SQLite and emits the same Cytoscape-shaped payload that `fetch_graph`
returns from Neo4j. Neo4j stays exclusive to the Maintain ingestion
pipeline. Shared DoclingDocument helpers live in `infra/docling_tree.py`
so TreeWriter and the builder can't drift on label taxonomy or tree walks.

Also removes the Cytoscape minimap (cytoscape-navigator) from GraphView:
second render instance hurt perf on large documents for no UX win.

Backend
- new `GET /api/documents/:id/reasoning-graph` (SQLite-only)
- new `infra/docling_tree.py`, `infra/docling_graph.py`
- `analysis_repo.find_latest_completed_by_document`
- tests: `test_docling_graph.py` (builder), `test_graph_api.py` (endpoint)

Frontend
- `features/reasoning/` — store, overlay, types, panel, import dialog,
  workspace, doc picker
- new `ReasoningPage` + `/reasoning` and `/reasoning/:docId` routes
- `GraphView` gains a `fetcher` prop so reasoning can inject the
  SQLite-backed fetcher while Maintain keeps using the Neo4j one
- drops minimap (nav container, dep, CSS)
- legend filters + section parenting extracted for reuse
- i18n base strings (FR + EN)
2026-04-21 11:30:47 +02:00

84 lines
2.4 KiB
TypeScript

import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { parseImportedTrace, useReasoningStore } from './store'
describe('parseImportedTrace', () => {
const bare = {
answer: 'ok',
converged: true,
iterations: [],
}
it('accepts a bare RAGResult', () => {
const parsed = parseImportedTrace(bare)
expect(parsed?.result.answer).toBe('ok')
expect(parsed?.envelope).toBeNull()
})
it('accepts a sidecar envelope and extracts the result', () => {
const parsed = parseImportedTrace({
job_id: 'abc',
filename: 'x.pdf',
result: bare,
})
expect(parsed?.result.answer).toBe('ok')
expect(parsed?.envelope?.job_id).toBe('abc')
})
it('rejects shapes that are neither envelope nor bare RAGResult', () => {
expect(parseImportedTrace(null)).toBeNull()
expect(parseImportedTrace('string')).toBeNull()
expect(parseImportedTrace({ foo: 'bar' })).toBeNull()
expect(parseImportedTrace({ answer: 'x' })).toBeNull() // missing converged + iterations
})
})
describe('useReasoningStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts empty', () => {
const s = useReasoningStore()
expect(s.hasTrace).toBe(false)
expect(s.iterations).toEqual([])
expect(s.presentCount).toBe(0)
})
it('setResult populates and resets active iteration', () => {
const s = useReasoningStore()
s.setActiveIteration(3)
s.setResult({ answer: 'a', converged: true, iterations: [] }, null)
expect(s.hasTrace).toBe(true)
expect(s.activeIteration).toBeNull()
})
it('toggleFocusMode flips the flag', () => {
const s = useReasoningStore()
expect(s.focusMode).toBe(true)
s.toggleFocusMode()
expect(s.focusMode).toBe(false)
s.toggleFocusMode()
expect(s.focusMode).toBe(true)
})
it('reset restores focusMode to the default on', () => {
const s = useReasoningStore()
s.toggleFocusMode()
expect(s.focusMode).toBe(false)
s.reset()
expect(s.focusMode).toBe(true)
})
it('reset clears everything including the dialog flag', () => {
const s = useReasoningStore()
s.openImportDialog()
s.setResult({ answer: 'a', converged: true, iterations: [] }, null)
s.setError('boom')
s.reset()
expect(s.hasTrace).toBe(false)
expect(s.error).toBeNull()
expect(s.importDialogOpen).toBe(false)
})
})