generated from coulomb/repo-seed
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>
501 lines
18 KiB
Python
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 == []
|