Files
marki-docx/tests/test_level3_diagrams.py
Bernd Worsch 9fe64bcd7f feat: WP-0007 — Interface Completeness & Evidence
T01: markidocx inspect (FR-806) and markidocx test (FR-810) CLI commands
T02: markidocx evidence get/list CLI commands (FR-1409, FR-814)
T03: list_styles() / GET /styles / MCP list_styles with real style data (FR-907)
T04: Evidence assembly — EvidenceSet summary via REST and MCP (FR-1406–1408)
T05: LEVEL3 edge-case tests — diagram mutation, renderer version check,
     bibliography duplicate keys / missing refs / special chars (FR-534, FR-538, FR-542)
T06: markidocx template extract + Word-first round-trip regression test (FR-606)

New: differ._compare_diagram_blocks tracks fenced diagram source drift (FR-534)
New: diagrams.check_renderer_version emits warning for outdated renderers (FR-538)
New: bibliography.validate_citations detects duplicate keys and missing entries (FR-542)
New: templates.extract_template / TemplateExtractionResult / list_styles / StyleEntry
New: REST POST /template/extract; MCP extract_template tool

278 tests pass, ruff+mypy clean.

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

501 lines
18 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
# ---------------------------------------------------------------------------
# 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 == []