docling-studio/frontend/src/features/reasoning/graphReasoningOverlay.test.ts
Pier-Jean Malandrino 8fe24a8efc refactor(audit): remediate 0.5.0 audit findings — clean architecture, security, DRY, SOLID, perf
Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).

Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
  * Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
  * Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
  * infra/llm/ollama_provider.py — OllamaProvider with health_check
  * infra/docling_agent_reasoning.py — runner adapter, encapsulates the
    private _rag_loop call (tracked at docling-project/docling-agent#26),
    commits OLLAMA_HOST once at boot (eliminates the per-request env race),
    translates upstream IndexError into ReasoningParseError
  * api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
    consumes app.state.reasoning_runner via the port
  * main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
    when REASONING_ENABLED=true and deps are importable
  * Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
    type RAGResult → ReasoningResult, frontend feature flag wiring,
    i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
    consumers in production)
  * 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
    httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
    serialization, R13 Protocol conformance via isinstance
  * E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
  * README — Live Reasoning section (env vars, archi, link to issue #26)

Bloc B — Security (audit 08, dev-only context)
  * docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
    flagged as dev-only with link to OpenSearch security docs
  * main.py — boot warning if NEO4J_URI is set with the default 'changeme'
    password, so prod operators can't silently inherit it

Bloc C — DRY frontend (audit 05)
  * shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
  * features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
  * api/schemas.py — DOCUMENT_STATUS_UPLOADED constant

Bloc D — Quality (audits 02/06/07/09/10/12)
  * domain/ports.py — DocumentConverter.supports_page_batching property
    (LSP fix, replaces isinstance(ServeConverter) check)
  * domain/ports.py — VectorStore.ping() (encapsulation, replaces
    _vector_store._client.info() reach-around)
  * api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
    aligned with the user-facing terminology (URLs unchanged)
  * api/documents.py — Path.read_bytes() + generate_preview() wrapped in
    asyncio.to_thread, unblocks the FastAPI event loop on /preview
  * infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
  * src/__tests__/integration/ — cross-feature integration test relocated
    out of features/history/ so feature folders stay self-contained
  * Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
    exact value comparisons)

Validation
  * 446 backend pytest, 202 frontend vitest — all green
  * ruff + ruff format + ESLint + Prettier + vue-tsc clean
  * Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO

Closes #200
2026-04-29 09:20:19 +02:00

247 lines
8.2 KiB
TypeScript

import cytoscape from 'cytoscape'
import type { Core } from 'cytoscape'
import { beforeEach, describe, expect, it } from 'vitest'
import {
REASONING_EDGE_TYPE,
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
focusIteration,
nodeIdForSectionRef,
} from './graphReasoningOverlay'
import type { ReasoningResult } from './types'
function seed(): Core {
// Headless mode — no DOM container needed.
return cytoscape({
headless: true,
elements: [
{ data: { id: 'elem::#/texts/0', group: 'element' } },
{ data: { id: 'elem::#/texts/3', group: 'element' } },
{ data: { id: 'elem::#/texts/7', group: 'element' } },
{ data: { id: 'elem::#/groups/1', group: 'element' } },
],
})
}
function result(
iterations: Array<Partial<ReasoningResult['iterations'][number]>>,
): ReasoningResult {
return {
answer: 'x',
converged: true,
iterations: iterations.map((it, i) => ({
iteration: i + 1,
section_ref: '',
reason: '',
section_text_length: 0,
can_answer: false,
response: '',
...it,
})),
}
}
describe('nodeIdForSectionRef', () => {
it('matches the backend _element_node id format', () => {
// Kept in sync with document-parser/infra/neo4j/queries.py::_element_node.
expect(nodeIdForSectionRef('#/texts/3')).toBe('elem::#/texts/3')
})
})
describe('applyReasoningOverlay', () => {
let cy: Core
beforeEach(() => {
cy = seed()
})
it('marks every resolvable section as visited with its iteration order', () => {
const res = applyReasoningOverlay(
cy,
result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]),
)
expect(res.presentCount).toBe(2)
expect(res.missingCount).toBe(0)
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(true)
expect(cy.getElementById('elem::#/texts/0').data('visitOrder')).toBe(1)
expect(cy.getElementById('elem::#/texts/3').data('visitOrder')).toBe(2)
})
it('adds a REASONING_NEXT edge between each consecutive visited pair', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/3' },
{ section_ref: '#/texts/7' },
]),
)
const edges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(edges.length).toBe(2)
const ids = edges.map((e) => e.id()).sort()
expect(ids).toEqual([
'REASONING_NEXT::elem::#/texts/0::elem::#/texts/3',
'REASONING_NEXT::elem::#/texts/3::elem::#/texts/7',
])
})
it('reports missing section refs and does not crash on them', () => {
const res = applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/999' }, // not in graph
{ section_ref: '#/texts/3' },
]),
)
expect(res.presentCount).toBe(2)
expect(res.missingCount).toBe(1)
expect(res.resolved[1].present).toBe(false)
expect(cy.getElementById('elem::#/texts/999').nonempty()).toBe(false)
})
it('breaks the arrow chain across a missing iteration (no ghost edges)', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/999' }, // missing
{ section_ref: '#/texts/3' },
]),
)
// Only the chain between present-to-present pairs gets edges. With one
// missing in the middle, we still draw 0→3 because the filter keeps the
// present ones adjacent in the sequence used for edge drawing. Assert it:
// one edge between 0 and 3.
const edges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(edges.length).toBe(1)
expect(edges[0].data('source')).toBe('elem::#/texts/0')
expect(edges[0].data('target')).toBe('elem::#/texts/3')
})
it('is idempotent — re-applying replaces the previous overlay', () => {
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }]))
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/3' }, { section_ref: '#/texts/7' }]))
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(false)
expect(cy.getElementById('elem::#/texts/3').hasClass('visited')).toBe(true)
expect(cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).length).toBe(1)
})
it('dims every non-visited node so the trace pops visually', () => {
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]))
// Visited ones keep their full opacity (no dimmed class).
expect(cy.getElementById('elem::#/texts/0').hasClass('dimmed')).toBe(false)
expect(cy.getElementById('elem::#/texts/3').hasClass('dimmed')).toBe(false)
// Everything else gets dimmed.
expect(cy.getElementById('elem::#/texts/7').hasClass('dimmed')).toBe(true)
expect(cy.getElementById('elem::#/groups/1').hasClass('dimmed')).toBe(true)
})
it('does not dim anything when the trace has no resolvable iterations', () => {
// All missing → we don't want to wash out the whole graph for nothing.
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/999' }]))
expect(cy.$('.dimmed').length).toBe(0)
})
it('skips dimming entirely when focusMode is off', () => {
applyReasoningOverlay(
cy,
result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]),
{ focusMode: false },
)
// Visited still highlighted...
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(true)
// ...but nothing else gets dimmed.
expect(cy.$('.dimmed').length).toBe(0)
})
it('does not dim the synthetic REASONING_NEXT edges', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/3' },
{ section_ref: '#/texts/7' },
]),
)
const reasoningEdges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(reasoningEdges.length).toBe(2)
reasoningEdges.forEach((e) => {
expect(e.hasClass('dimmed')).toBe(false)
})
})
it('preserves the original graph elements (no destructive mutations)', () => {
const beforeIds = cy
.elements()
.map((e) => e.id())
.sort()
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }]))
clearReasoningOverlay(cy)
const afterIds = cy
.elements()
.map((e) => e.id())
.sort()
expect(afterIds).toEqual(beforeIds)
})
})
describe('clearReasoningOverlay', () => {
it('removes class, data, dimming, and synthetic edges', () => {
const cy = seed()
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]))
clearReasoningOverlay(cy)
expect(cy.$('.visited').length).toBe(0)
expect(cy.$('.dimmed').length).toBe(0)
expect(cy.getElementById('elem::#/texts/0').data('visitOrder')).toBeUndefined()
expect(cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).length).toBe(0)
})
it('is a no-op when nothing is overlaid', () => {
const cy = seed()
expect(() => clearReasoningOverlay(cy)).not.toThrow()
})
})
describe('buildDegradedOverlay', () => {
// Used when the Neo4j graph isn't loaded — we still want to show the
// reasoning trace cards, just without graph positioning.
it('returns every iteration with present=false', () => {
const out = buildDegradedOverlay(
result([
{ section_ref: '#/texts/0', reason: 'r0', can_answer: false },
{ section_ref: '#/texts/3', reason: 'r1', can_answer: true, response: 'done' },
]),
)
expect(out.resolved).toHaveLength(2)
expect(out.presentCount).toBe(0)
expect(out.missingCount).toBe(2)
expect(out.resolved.every((r) => !r.present)).toBe(true)
expect(out.resolved[0].reason).toBe('r0')
expect(out.resolved[1].canAnswer).toBe(true)
expect(out.resolved[1].response).toBe('done')
expect(out.resolved[0].nodeId).toBe('elem::#/texts/0')
})
it('handles an empty iterations list', () => {
const out = buildDegradedOverlay(result([]))
expect(out.resolved).toEqual([])
expect(out.presentCount).toBe(0)
expect(out.missingCount).toBe(0)
})
})
describe('focusIteration', () => {
it('is a no-op for a missing node', () => {
const cy = seed()
expect(() => focusIteration(cy, 'elem::#/texts/does-not-exist')).not.toThrow()
})
})