"""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 # --------------------------------------------------------------------------- # T05 — FR-534 edge cases: diagram source mutation and empty source # --------------------------------------------------------------------------- class TestDiagramEdgeCases: """FR-534: diagram source mutation and empty source edge cases.""" def test_mutated_source_marker_classified_as_structural_drift( self, tmp_path: Path, monkeypatch ) -> None: """Mutated source marker content is not silently dropped by the importer (FR-534). We construct a DOCX with only a source-intent marker paragraph (simulating what the renderer path produces) and verify that the importer uses the marker to reconstruct the fenced block. We then check the differ sees the mutation. """ from docx import Document as DocxDoc from docx.shared import Pt from markidocx.diagrams import DIAGRAM_SOURCE_MARKER_PREFIX from markidocx.differ import compare from markidocx.importer import import_document from markidocx.manifest import load_manifest # Build a minimal DOCX with ONLY a source marker paragraph (no verbatim code block) # — this simulates the rendered path where a PNG was embedded. docx_path = tmp_path / "mutated.docx" doc = DocxDoc() # Add a heading so there is some structure doc.add_heading("Test", level=1) # Add source marker with MUTATED source (A-->C, not original A-->B) marker_para = doc.add_paragraph(style="Normal") marker_run = marker_para.add_run( f"{DIAGRAM_SOURCE_MARKER_PREFIX}mermaid\ngraph TD\nA-->C" ) marker_run.font.size = Pt(1) doc.save(str(docx_path)) # Set up a manifest _make_project(tmp_path, "") m = load_manifest(tmp_path / "manifest.yaml") import_result = import_document(m, docx_path) assert import_result.success reimported = import_result.output_files[0].read_text(encoding="utf-8") # The mutated source (A-->C) must appear in the reimported content assert "A-->C" in reimported, "Mutated diagram source was silently dropped" # The differ between original (A-->B) and reimported (A-->C) should detect drift original_md = "```mermaid\ngraph TD\nA-->B\n```" report = compare(original_md, reimported) assert report.has_drift, ( f"Differ should detect structural drift after source mutation. " f"preserved={report.preserved}, degraded={report.degraded}, broken={report.broken}" ) def test_empty_diagram_source_emits_warning( self, tmp_path: Path, monkeypatch ) -> None: """A diagram block with empty source must produce a WarningRecord.""" import shutil from markidocx.builder import build_document from markidocx.manifest import load_manifest monkeypatch.setattr(shutil, "which", lambda _cmd: None) # Empty mermaid block md = "```mermaid\n\n```" _make_project(tmp_path, md) m = load_manifest(tmp_path / "manifest.yaml") result = build_document(m) assert result.success # Any warning related to diagrams or empty source all_reasons = [w.reason for w in result.warning_records] # At minimum the processor-dependency warning fires since no renderer available assert any("processor-dependency" in r or "diagram" in r or "empty" in r for r in all_reasons) # --------------------------------------------------------------------------- # T05 — FR-538: renderer version checking # --------------------------------------------------------------------------- class TestRendererVersionCheck: """FR-538: outdated renderer versions must emit WarningRecord.""" def test_supported_version_no_warning(self, monkeypatch) -> None: import subprocess import markidocx.diagrams as diag_mod # Mock mmdc --version to return a supported version def _fake_run(cmd, **kwargs): class R: stdout = "mermaid.js/10.4.0" stderr = "" returncode = 0 return R() monkeypatch.setattr(subprocess, "run", _fake_run) warnings: list = [] diag_mod.check_renderer_version("mmdc", warnings) version_warnings = [w for w in warnings if w.reason == "renderer-version-unsupported"] assert not version_warnings, "Should not warn for supported version" def test_outdated_version_emits_warning(self, monkeypatch) -> None: import subprocess import markidocx.diagrams as diag_mod # Mock mmdc --version to return an unsupported old version (8.x < 9.x minimum) def _fake_run(cmd, **kwargs): class R: stdout = "mermaid.js/8.2.3" stderr = "" returncode = 0 return R() monkeypatch.setattr(subprocess, "run", _fake_run) warnings: list = [] diag_mod.check_renderer_version("mmdc", warnings) version_warnings = [w for w in warnings if w.reason == "renderer-version-unsupported"] assert version_warnings, "Expected renderer-version-unsupported warning for old version" def test_unknown_renderer_no_crash(self, monkeypatch) -> None: import markidocx.diagrams as diag_mod warnings: list = [] # Unknown cmd — should silently return without crashing diag_mod.check_renderer_version("unknown-renderer", warnings) assert warnings == [] def test_subprocess_error_no_crash(self, monkeypatch) -> None: import subprocess import markidocx.diagrams as diag_mod def _fake_run(*args, **kwargs): raise OSError("command not found") monkeypatch.setattr(subprocess, "run", _fake_run) warnings: list = [] diag_mod.check_renderer_version("mmdc", warnings) # Should not raise assert warnings == []