Files
marki-docx/tests/test_level3_diagrams.py
Bernd Worsch 5564747060 feat: WP-0005 — diagram renderer integration (Mermaid, Graphviz, PlantUML)
Add pluggable DiagramRenderer protocol and RendererResult type to diagrams.py.
Implement MermaidRenderer (mmdc), GraphvizRenderer (dot), PlantUMLRenderer
backends with graceful source-only fallback and WarningRecord on missing tool
(FR-533, FR-534, FR-538). Builder detects renderers at build time and embeds
PNG with alt-text source marker for round-trip. Extend regression corpus with
rendered_diagrams_document.md and skipif-gated integration tests. All 272 tests
pass; ruff and mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:16:09 +00:00

348 lines
12 KiB
Python

"""Tests for LEVEL3 auto-diagram support (FR-533, FR-534)."""
from __future__ import annotations
import textwrap
from pathlib import Path
LEVEL3_MANIFEST = textwrap.dedent("""\
project:
name: diag-test
feature_level: level3
family: article
sources:
- path: doc.md
output:
dir: ./dist
""")
def _make_project(tmp_path: Path, markdown: str) -> Path:
(tmp_path / "doc.md").write_text(markdown, encoding="utf-8")
(tmp_path / "manifest.yaml").write_text(LEVEL3_MANIFEST, encoding="utf-8")
return tmp_path
# ---------------------------------------------------------------------------
# diagrams module helpers
# ---------------------------------------------------------------------------
class TestDiagramHelpers:
def test_is_diagram_info_mermaid(self) -> None:
from markidocx.diagrams import is_diagram_info
assert is_diagram_info("mermaid")
def test_is_diagram_info_graphviz(self) -> None:
from markidocx.diagrams import is_diagram_info
assert is_diagram_info("graphviz")
def test_is_diagram_info_plantuml(self) -> None:
from markidocx.diagrams import is_diagram_info
assert is_diagram_info("plantuml")
def test_is_diagram_info_python_false(self) -> None:
from markidocx.diagrams import is_diagram_info
assert not is_diagram_info("python")
assert not is_diagram_info("")
assert not is_diagram_info(None)
def test_is_diagram_source_marker(self) -> None:
from markidocx.diagrams import is_diagram_source_marker
assert is_diagram_source_marker("diagram-source:mermaid\ngraph TD\nA-->B")
assert not is_diagram_source_marker("normal text")
def test_parse_diagram_source_marker(self) -> None:
from markidocx.diagrams import parse_diagram_source_marker
source = "graph TD\nA-->B"
result = parse_diagram_source_marker(f"diagram-source:mermaid\n{source}")
assert result is not None
diagram_type, parsed_source = result
assert diagram_type == "mermaid"
assert parsed_source == source
def test_reconstruct_diagram_md(self) -> None:
from markidocx.diagrams import reconstruct_diagram_md
result = reconstruct_diagram_md("mermaid", "graph TD\nA-->B")
assert result.startswith("```mermaid")
assert "graph TD" in result
assert result.endswith("```")
# ---------------------------------------------------------------------------
# Builder: diagram blocks → source-only path (no renderer in test env) (FR-533)
# ---------------------------------------------------------------------------
class TestBuilderDiagrams:
def test_build_with_mermaid_block_succeeds(self, tmp_path: Path) -> None:
"""Mermaid block builds without error (source-only path)."""
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
md = textwrap.dedent("""\
# Document
```mermaid
graph TD
A --> B --> C
```
Some text.
""")
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
assert result.success
def test_build_emits_warning_for_unavailable_renderer(
self, tmp_path: Path, monkeypatch
) -> None:
"""Warns about missing diagram renderer (FR-538)."""
import shutil
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
md = "```mermaid\ngraph TD\nA-->B\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
assert result.success
dep_warnings = [
w for w in result.warning_records
if w.reason == "processor-dependency-unavailable"
]
assert dep_warnings
def test_build_docx_contains_source_marker(
self, tmp_path: Path, monkeypatch
) -> None:
"""DOCX contains diagram-source marker for round-trip."""
import shutil
from docx import Document as DocxReader
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
md = "```mermaid\ngraph TD\nA-->B\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
assert result.success
doc = DocxReader(str(result.output_path))
texts = [p.text for p in doc.paragraphs]
marker_texts = [t for t in texts if t.startswith("diagram-source:")]
assert marker_texts, f"No diagram-source marker found. Paragraphs: {texts}"
def test_build_graphviz_block_succeeds(self, tmp_path: Path) -> None:
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
md = "```graphviz\ndigraph G { A -> B }\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
assert result.success
def test_non_diagram_code_block_not_warned(
self, tmp_path: Path
) -> None:
"""Python code blocks don't trigger diagram warnings."""
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
md = "```python\nprint('hello')\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
dep_warnings = [
w for w in result.warning_records
if w.reason == "processor-dependency-unavailable"
]
# Only level3 diagram types trigger this warning, not python
# (may still warn for mmdc/dot if level3 partial check fires, but not for python block)
python_warnings = [w for w in dep_warnings if "python" in w.construct]
assert not python_warnings
# ---------------------------------------------------------------------------
# Importer: diagram source-intent marker → fenced block (FR-534)
# ---------------------------------------------------------------------------
class TestImporterDiagrams:
def test_roundtrip_source_only_path(self, tmp_path: Path, monkeypatch) -> None:
"""Source-only round-trip: diagram source is preserved in reimported MD."""
import shutil
from markidocx.builder import build_document
from markidocx.importer import import_document
from markidocx.manifest import load_manifest
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
diagram_source = "graph TD\nA --> B --> C"
md = f"# Document\n\n```mermaid\n{diagram_source}\n```\n\nText."
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
build_result = build_document(m)
assert build_result.success
import_result = import_document(m, build_result.output_path)
assert import_result.success
reimported = import_result.output_files[0].read_text(encoding="utf-8")
assert "mermaid" in reimported
assert "graph TD" in reimported
def test_no_source_discarded(self, tmp_path: Path, monkeypatch) -> None:
"""Diagram source is never silently dropped (FR-1205)."""
import shutil
from markidocx.builder import build_document
from markidocx.importer import import_document
from markidocx.manifest import load_manifest
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
md = "```plantuml\n@startuml\nAlice -> Bob: Hi\n@enduml\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
build_result = build_document(m)
assert build_result.success
import_result = import_document(m, build_result.output_path)
assert import_result.success
reimported = import_result.output_files[0].read_text(encoding="utf-8")
# Source content must be present somewhere in the reimported text
assert "plantuml" in reimported or "@startuml" in reimported
# ---------------------------------------------------------------------------
# Renderer abstraction — detection and fallback (T01)
# ---------------------------------------------------------------------------
class TestRendererDetection:
def test_detect_renderers_returns_empty_when_no_tools(
self, monkeypatch
) -> None:
"""detect_renderers() returns empty dict when no CLI tools found."""
import shutil
import markidocx.diagrams as diag_mod
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
# Patch backends to use patched shutil
monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [
diag_mod.MermaidRenderer(),
diag_mod.GraphvizRenderer(),
diag_mod.PlantUMLRenderer(),
])
result = diag_mod.detect_renderers()
assert result == {}
def test_detect_renderers_returns_mermaid_when_mmdc_available(
self, monkeypatch
) -> None:
"""detect_renderers() includes mermaid when mmdc is on PATH."""
import shutil
import markidocx.diagrams as diag_mod
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/mmdc" if cmd == "mmdc" else None)
monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [
diag_mod.MermaidRenderer(),
diag_mod.GraphvizRenderer(),
diag_mod.PlantUMLRenderer(),
])
result = diag_mod.detect_renderers()
assert "mermaid" in result
assert isinstance(result["mermaid"], diag_mod.MermaidRenderer)
def test_detect_renderers_returns_graphviz_when_dot_available(
self, monkeypatch
) -> None:
"""detect_renderers() includes graphviz when dot is on PATH."""
import shutil
import markidocx.diagrams as diag_mod
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/dot" if cmd == "dot" else None)
monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [
diag_mod.MermaidRenderer(),
diag_mod.GraphvizRenderer(),
diag_mod.PlantUMLRenderer(),
])
result = diag_mod.detect_renderers()
assert "graphviz" in result
assert isinstance(result["graphviz"], diag_mod.GraphvizRenderer)
def test_detect_renderers_returns_plantuml_when_available(
self, monkeypatch
) -> None:
"""detect_renderers() includes plantuml when plantuml is on PATH."""
import shutil
import markidocx.diagrams as diag_mod
monkeypatch.setattr(
shutil, "which",
lambda cmd: "/usr/bin/plantuml" if cmd == "plantuml" else None,
)
monkeypatch.setattr(diag_mod, "_ALL_BACKENDS", [
diag_mod.MermaidRenderer(),
diag_mod.GraphvizRenderer(),
diag_mod.PlantUMLRenderer(),
])
result = diag_mod.detect_renderers()
assert "plantuml" in result
assert isinstance(result["plantuml"], diag_mod.PlantUMLRenderer)
def test_fallback_when_no_renderer(self, tmp_path: Path, monkeypatch) -> None:
"""Source-only fallback fires and warning emitted when no renderer available."""
import shutil
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
md = "```mermaid\ngraph TD\nA-->B\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
assert result.success
dep_warnings = [
w for w in result.warning_records
if w.reason == "processor-dependency-unavailable"
]
assert dep_warnings, "Expected processor-dependency-unavailable warning"
def test_renderer_result_dataclass(self) -> None:
"""RendererResult dataclass has expected fields."""
from pathlib import Path
from markidocx.diagrams import RendererResult
r = RendererResult(success=True, output_path=Path("/tmp/out.png"))
assert r.success
assert r.output_path == Path("/tmp/out.png")
assert r.warning is None
r2 = RendererResult(success=False)
assert not r2.success
assert r2.output_path is None