generated from coulomb/repo-seed
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>
231 lines
7.9 KiB
Python
231 lines
7.9 KiB
Python
"""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
|