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>
This commit is contained in:
2026-03-17 19:30:09 +00:00
parent 893b9fa57b
commit 9fe64bcd7f
16 changed files with 1537 additions and 19 deletions

View File

@@ -345,3 +345,156 @@ class TestRendererDetection:
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 == []