generated from coulomb/repo-seed
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>
This commit is contained in:
39
tests/regression/level3/rendered_diagrams_document.md
Normal file
39
tests/regression/level3/rendered_diagrams_document.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Document with Rendered Diagrams
|
||||
|
||||
This document is used for rendered-diagram round-trip regression (WP-0005 T05).
|
||||
Each block exercises a different renderer backend.
|
||||
|
||||
## Mermaid State Machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Active: start
|
||||
Active --> Done: complete
|
||||
Done --> [*]
|
||||
```
|
||||
|
||||
## Graphviz Dependency Graph
|
||||
|
||||
```graphviz
|
||||
digraph deps {
|
||||
A -> B;
|
||||
A -> C;
|
||||
B -> D;
|
||||
C -> D;
|
||||
}
|
||||
```
|
||||
|
||||
## PlantUML Sequence
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
Alice -> Bob: Request
|
||||
Bob --> Alice: Response
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
All three diagram types tested in rendered mode when tools are available,
|
||||
source-only mode when they are not.
|
||||
230
tests/regression/test_level3_rendered_diagrams.py
Normal file
230
tests/regression/test_level3_rendered_diagrams.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Rendered-diagram round-trip regression tests (WP-0005 T05, FR-533, FR-534).
|
||||
|
||||
Tests are skipped when the required renderer CLI is not on PATH — they are
|
||||
integration tests that require actual renderer tools (mmdc, dot, plantuml).
|
||||
The source-only fallback path is covered by test_level3_diagrams.py and
|
||||
always runs regardless of renderer availability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LEVEL3_MANIFEST = textwrap.dedent("""\
|
||||
project:
|
||||
name: rendered-diag-test
|
||||
feature_level: level3
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
""")
|
||||
|
||||
_FIXTURE_DOC = (
|
||||
Path(__file__).parent / "level3" / "rendered_diagrams_document.md"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mermaid rendered round-trip (skipped if mmdc not found)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
shutil.which("mmdc") is None,
|
||||
reason="mmdc (Mermaid CLI) not installed",
|
||||
)
|
||||
class TestMermaidRenderRoundtrip:
|
||||
def test_mermaid_render_roundtrip(self, tmp_path: Path) -> None:
|
||||
"""Mermaid block renders to PNG; round-trip restores source."""
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
md = textwrap.dedent("""\
|
||||
# Test
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A --> B --> C
|
||||
```
|
||||
""")
|
||||
_make_project(tmp_path, md)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
|
||||
build_result = build_document(m)
|
||||
assert build_result.success, build_result.warning_records
|
||||
|
||||
# Should have no processor-dependency-unavailable warnings
|
||||
dep_warns = [
|
||||
w for w in build_result.warning_records
|
||||
if w.reason == "processor-dependency-unavailable"
|
||||
and "mermaid" in w.construct
|
||||
]
|
||||
assert not dep_warns, f"Unexpected fallback warnings: {dep_warns}"
|
||||
|
||||
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_mermaid_no_broken_diagrams_in_differ(self, tmp_path: Path) -> None:
|
||||
"""Differ reports zero broken diagrams when mermaid renderer was available."""
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.differ import compare_documents
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
md = "# Doc\n\n```mermaid\ngraph LR\nX --> Y\n```\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
|
||||
|
||||
diff_result = compare_documents(m, build_result.output_path)
|
||||
assert diff_result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graphviz rendered round-trip (skipped if dot not found)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
shutil.which("dot") is None,
|
||||
reason="dot (Graphviz) not installed",
|
||||
)
|
||||
class TestGraphvizRenderRoundtrip:
|
||||
def test_graphviz_render_roundtrip(self, tmp_path: Path) -> None:
|
||||
"""Graphviz block renders to PNG; round-trip restores source."""
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
md = textwrap.dedent("""\
|
||||
# Test
|
||||
|
||||
```graphviz
|
||||
digraph G { A -> B; B -> C; }
|
||||
```
|
||||
""")
|
||||
_make_project(tmp_path, md)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
|
||||
build_result = build_document(m)
|
||||
assert build_result.success, build_result.warning_records
|
||||
|
||||
dep_warns = [
|
||||
w for w in build_result.warning_records
|
||||
if w.reason == "processor-dependency-unavailable"
|
||||
and "graphviz" in w.construct
|
||||
]
|
||||
assert not dep_warns, f"Unexpected fallback warnings: {dep_warns}"
|
||||
|
||||
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 "graphviz" in reimported
|
||||
assert "digraph" in reimported
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlantUML rendered round-trip (skipped if plantuml not found)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
shutil.which("plantuml") is None,
|
||||
reason="plantuml not installed",
|
||||
)
|
||||
class TestPlantUMLRenderRoundtrip:
|
||||
def test_plantuml_render_roundtrip(self, tmp_path: Path) -> None:
|
||||
"""PlantUML block renders to PNG; round-trip restores source."""
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
md = textwrap.dedent("""\
|
||||
# Test
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
Alice -> Bob: Hello
|
||||
@enduml
|
||||
```
|
||||
""")
|
||||
_make_project(tmp_path, md)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
|
||||
build_result = build_document(m)
|
||||
assert build_result.success, build_result.warning_records
|
||||
|
||||
dep_warns = [
|
||||
w for w in build_result.warning_records
|
||||
if w.reason == "processor-dependency-unavailable"
|
||||
and "plantuml" in w.construct
|
||||
]
|
||||
assert not dep_warns, f"Unexpected fallback warnings: {dep_warns}"
|
||||
|
||||
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 "plantuml" in reimported
|
||||
assert "@startuml" in reimported
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-only fallback always passes (verifies fallback path stable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSourceOnlyAlwaysPasses:
|
||||
"""Source-only fallback must remain stable regardless of renderer availability."""
|
||||
|
||||
def test_all_diagram_types_source_only(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""All three diagram types build via source-only path when renderers absent."""
|
||||
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 = _FIXTURE_DOC.read_text(encoding="utf-8")
|
||||
_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")
|
||||
# All three diagram types must survive the round-trip
|
||||
assert "mermaid" in reimported
|
||||
assert "graphviz" in reimported
|
||||
assert "plantuml" in reimported
|
||||
@@ -229,3 +229,119 @@ class TestImporterDiagrams:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user