From 9fe64bcd7f3f3778711c47d0b39dadf6f0125889 Mon Sep 17 00:00:00 2001 From: Bernd Worsch Date: Tue, 17 Mar 2026 19:30:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20WP-0007=20=E2=80=94=20Interface=20Compl?= =?UTF-8?q?eteness=20&=20Evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/markidocx/bibliography.py | 47 ++++ src/markidocx/cli.py | 234 +++++++++++++++++ src/markidocx/diagrams.py | 50 ++++ src/markidocx/differ.py | 35 +++ src/markidocx/mcp_server.py | 52 +++- src/markidocx/rest.py | 57 ++++- src/markidocx/templates.py | 130 +++++++++- .../fixtures/word_first/generate.py | 55 ++++ .../fixtures/word_first/source.docx | Bin 0 -> 37460 bytes tests/regression/test_word_first_roundtrip.py | 236 ++++++++++++++++++ tests/test_cli_evidence.py | 125 ++++++++++ tests/test_cli_inspect_test.py | 135 ++++++++++ tests/test_level3_bibliography.py | 85 +++++++ tests/test_level3_diagrams.py | 153 ++++++++++++ tests/test_styles.py | 147 +++++++++++ ...WP-0007-interface-completeness-evidence.md | 15 +- 16 files changed, 1537 insertions(+), 19 deletions(-) create mode 100644 tests/regression/fixtures/word_first/generate.py create mode 100644 tests/regression/fixtures/word_first/source.docx create mode 100644 tests/regression/test_word_first_roundtrip.py create mode 100644 tests/test_cli_evidence.py create mode 100644 tests/test_cli_inspect_test.py create mode 100644 tests/test_styles.py diff --git a/src/markidocx/bibliography.py b/src/markidocx/bibliography.py index b3f159a..26a9848 100644 --- a/src/markidocx/bibliography.py +++ b/src/markidocx/bibliography.py @@ -206,3 +206,50 @@ def compare_citations( preserved.append(f"reference-entry:{key}") else: degraded.append(f"reference-entry:lost '{key}'") + + +# --------------------------------------------------------------------------- +# Validation helpers (FR-542) +# --------------------------------------------------------------------------- + + +def validate_citations(md_text: str) -> list: + """Validate citation consistency in *md_text*. + + Returns a list of WarningRecord for: + - duplicate citation keys in the references section + - citation keys with no corresponding reference entry + """ + from markidocx.errors import Severity, WarningRecord + + warnings: list[WarningRecord] = [] + + inline_keys = extract_citation_keys(md_text) + entries, _ = extract_references_section(md_text) + + # Check for duplicate keys in references section + seen_keys: set[str] = set() + for key, _ in entries: + if key in seen_keys: + warnings.append( + WarningRecord( + severity=Severity.WARNING, + reason="citation-duplicate-key", + construct=f"@{key}", + ) + ) + seen_keys.add(key) + + # Check for inline citations with no reference entry + ref_keys = {k for k, _ in entries} + for key in inline_keys: + if key not in ref_keys: + warnings.append( + WarningRecord( + severity=Severity.WARNING, + reason="citation-key-missing", + construct=f"@{key}", + ) + ) + + return warnings diff --git a/src/markidocx/cli.py b/src/markidocx/cli.py index b79554a..5f2d1b6 100644 --- a/src/markidocx/cli.py +++ b/src/markidocx/cli.py @@ -19,6 +19,8 @@ app = typer.Typer( ) template_app = typer.Typer(help="Template family management.") app.add_typer(template_app, name="template") +evidence_app = typer.Typer(help="Evidence store access.") +app.add_typer(evidence_app, name="evidence") def _version_callback(value: bool) -> None: @@ -221,6 +223,168 @@ def compare( raise typer.Exit(1 if report.has_drift else 0) +@app.command() +def inspect( + manifest: Annotated[Path, typer.Argument(help="Path to manifest YAML file")], + json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, +) -> None: + """Inspect a project manifest and display its structure (FR-806).""" + from markidocx.level3 import capabilities_entry as level3_capabilities + from markidocx.manifest import ManifestError, load_manifest + + try: + m = load_manifest(manifest) + except ManifestError as exc: + if json_output: + typer.echo(json.dumps({"status": "error", "message": str(exc)})) + else: + err_console.print(f"[red]✗ Manifest error:[/red] {exc}") + raise typer.Exit(1) from None + + sources = [str(s.path) for s in m.sources] + result = { + "status": "ok", + "project": m.project.name, + "family": m.project.family, + "feature_level": m.project.feature_level.value, + "sources": sources, + "level3": level3_capabilities(), + } + + if json_output: + typer.echo(json.dumps(result)) + else: + console.print(f"[bold]Project:[/bold] {m.project.name}") + console.print(f" family: {m.project.family}") + console.print(f" feature_level: {m.project.feature_level.value}") + console.print(f" sources: {', '.join(sources)}") + l3_raw = result.get("level3") + l3: dict[str, object] = l3_raw if isinstance(l3_raw, dict) else {} + console.print(f" level3 xref: {l3.get('xref_available', False)}") + console.print(f" level3 fig: {l3.get('figures_available', False)}") + console.print(f" level3 diag: {l3.get('diagrams_available', False)}") + console.print(f" level3 bib: {l3.get('bibliography_available', False)}") + + raise typer.Exit(0) + + +@app.command("test") +def run_tests( + manifest: Annotated[Path, typer.Argument(help="Path to manifest YAML file")], + json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, +) -> None: + """Run the end-to-end regression test suite for a project (FR-810).""" + from markidocx.workflows import WorkflowError, run_workflow + + try: + result = run_workflow("single-file-roundtrip", manifest) + except WorkflowError as exc: + if json_output: + typer.echo(json.dumps({"status": "error", "message": str(exc)})) + else: + err_console.print(f"[red]✗ Workflow error:[/red] {exc}") + raise typer.Exit(1) from None + + passed = sum(1 for s in result.steps if s.status == "executed") + failed = sum(1 for s in result.steps if s.status == "failed") + skipped = sum(1 for s in result.steps if s.status not in ("executed", "failed")) + overall_ok = result.classification != "failed" + + if json_output: + typer.echo( + json.dumps( + { + "status": "ok" if overall_ok else "error", + "run_id": result.run_id, + "classification": result.classification, + "passed": passed, + "failed": failed, + "skipped": skipped, + "steps": [ + {"name": s.name, "status": s.status, "error": s.error} + for s in result.steps + ], + } + ) + ) + else: + icon = "[green]✓[/green]" if overall_ok else "[red]✗[/red]" + console.print(f"{icon} Tests: {passed} passed, {failed} failed, {skipped} skipped") + for step in result.steps: + step_icon = "✓" if step.status == "executed" else ("✗" if step.status == "failed" else "—") + console.print(f" {step_icon} {step.name}: {step.status}") + if result.run_id: + console.print(f" run_id: {result.run_id}") + + raise typer.Exit(0 if overall_ok else 1) + + +# --------------------------------------------------------------------------- +# Evidence commands (T02 — FR-1409, FR-814) +# --------------------------------------------------------------------------- + + +@evidence_app.command("list") +def evidence_list( + limit: Annotated[int, typer.Option("--limit", help="Maximum runs to show")] = 10, + json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, +) -> None: + """List run IDs in the evidence store, newest first.""" + from markidocx.evidence import EvidenceStore + + store = EvidenceStore() + runs = list(reversed(store.list_runs()))[:limit] + + if json_output: + typer.echo(json.dumps({"runs": runs})) + else: + if not runs: + console.print("No evidence runs found.") + else: + for run_id in runs: + console.print(run_id) + + +@evidence_app.command("get") +def evidence_get( + run_id: Annotated[str, typer.Argument(help="Run ID to retrieve")], + json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, + output: Annotated[Path | None, typer.Option("--output", help="Write evidence JSON to file")] = None, +) -> None: + """Retrieve and display evidence for a completed run (FR-1409).""" + from markidocx.evidence import EvidenceStore + + store = EvidenceStore() + reports = store.list_reports(run_id) + + if not reports: + if json_output: + typer.echo(json.dumps({"status": "not_found", "run_id": run_id})) + else: + err_console.print(f"[red]✗[/red] No evidence found for run_id: {run_id}") + raise typer.Exit(1) + + ev_set = store.assemble_set([run_id]) + summary = ev_set.summary() + + if output: + output.write_text(json.dumps({"run_id": run_id, "reports": [r.to_dict() for r in reports]}, indent=2), encoding="utf-8") + + if json_output: + typer.echo(json.dumps({"run_id": run_id, **summary})) + else: + classification = summary["classification"] + icon = "[green]✓[/green]" if classification == "pass" else ("[yellow]⚠[/yellow]" if "warning" in classification else "[red]✗[/red]") + console.print(f"{icon} Run: [bold]{run_id}[/bold] [{classification}]") + console.print(f" Reports: {summary['report_count']}") + console.print(f" Warnings: {summary['warnings_count']}") + console.print(f" Errors: {summary['errors_count']}") + for comp in summary["composition"]: + console.print(f" • {comp['type']} ({comp['run_id'][:8]}…)") + + raise typer.Exit(0) + + @template_app.command("list") def template_list( json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, @@ -273,6 +437,76 @@ def template_register( raise typer.Exit(1) from None +@template_app.command("styles") +def template_styles( + family: Annotated[str | None, typer.Option("--family", help="Filter by family name")] = None, + json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, +) -> None: + """List available styles for a template family (FR-907).""" + from markidocx.templates import list_styles + + entries = list_styles(family=family) + + if json_output: + typer.echo( + json.dumps( + [ + { + "name": e.name, + "style_id": e.style_id, + "type": e.type, + "family": e.family, + "built_in": e.built_in, + } + for e in entries + ] + ) + ) + else: + table = Table(title=f"Styles{' — ' + family if family else ''}") + table.add_column("Name", style="bold") + table.add_column("ID") + table.add_column("Type") + table.add_column("Family") + for e in entries: + table.add_row(e.name, e.style_id, e.type, e.family) + console.print(table) + + +@template_app.command("extract") +def template_extract( + source: Annotated[Path, typer.Argument(help="Source DOCX to extract template from")], + template_out: Annotated[Path | None, typer.Option("--template-out", help="Output template path")] = None, + content_out: Annotated[Path | None, typer.Option("--content-out", help="Output Markdown content path")] = None, + family: Annotated[str | None, typer.Option("--family", help="Register extracted template under this name")] = None, + json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output")] = False, +) -> None: + """Extract a content-free template shell from an existing DOCX (FR-606).""" + from markidocx.templates import extract_template + + if template_out is None: + template_out = source.parent / (source.stem + "-template.docx") + + result = extract_template(source, template_out, family=family) + + if json_output: + typer.echo( + json.dumps( + { + "status": "ok", + "template_path": str(result.template_path), + "styles_preserved": result.styles_preserved, + "warnings": [w.to_dict() for w in result.warnings], + } + ) + ) + else: + console.print(f"[green]✓[/green] Template extracted: [bold]{result.template_path}[/bold]") + console.print(f" Styles preserved: {result.styles_preserved}") + for w in result.warnings: + console.print(f"[yellow]⚠[/yellow] {w}") + + @app.command() def serve( host: Annotated[str, typer.Option("--host", help="Bind host")] = "127.0.0.1", diff --git a/src/markidocx/diagrams.py b/src/markidocx/diagrams.py index e1af90a..9e25f23 100644 --- a/src/markidocx/diagrams.py +++ b/src/markidocx/diagrams.py @@ -205,6 +205,56 @@ def detect_renderers() -> dict[str, DiagramRenderer]: return available +# Minimum supported major versions for each diagram renderer (FR-538) +_MIN_RENDERER_VERSIONS: dict[str, tuple[int, ...]] = { + "mmdc": (9, 0), # Mermaid CLI >= 9.x + "dot": (2, 40), # Graphviz >= 2.40 + "plantuml": (1, 50), # PlantUML >= 1.50 +} + + +def check_renderer_version( + cmd: str, warning_records: list +) -> None: + """Check the renderer CLI version and emit a warning if outdated (FR-538). + + Runs ``cmd --version`` (or ``cmd -version`` for plantuml), parses the + first version-like token, and appends a WarningRecord if the version is + below the minimum. + """ + min_ver = _MIN_RENDERER_VERSIONS.get(cmd) + if min_ver is None: + return + + version_flags = ["-version"] if cmd == "plantuml" else ["--version"] + try: + proc = subprocess.run( + [cmd] + version_flags, + capture_output=True, + timeout=5, + text=True, + ) + output = proc.stdout or proc.stderr + except Exception: + return # Can't probe — don't warn + + # Extract first numeric token like "10.4.0" or "2.42.2" + import re as _re + m = _re.search(r"(\d+)\.(\d+)", output) + if not m: + return + + major, minor = int(m.group(1)), int(m.group(2)) + if (major, minor) < min_ver: + warning_records.append( + WarningRecord( + severity=Severity.WARNING, + reason="renderer-version-unsupported", + construct=f"{cmd} {major}.{minor} (min {min_ver[0]}.{min_ver[1]})", + ) + ) + + # --------------------------------------------------------------------------- # Public helpers # --------------------------------------------------------------------------- diff --git a/src/markidocx/differ.py b/src/markidocx/differ.py index 2b459dd..c9e674a 100644 --- a/src/markidocx/differ.py +++ b/src/markidocx/differ.py @@ -85,6 +85,9 @@ def compare(original: str, reimported: str) -> DriftReport: # --- Figures (FR-532, FR-541) --- _compare_figures(original, reimported, preserved, degraded, broken) + # --- Diagram source blocks (FR-534) --- + _compare_diagram_blocks(original, reimported, preserved, degraded, broken) + # --- Citations & Bibliography (FR-535, FR-542) --- from markidocx.bibliography import compare_citations @@ -181,6 +184,38 @@ def _compare_xrefs( degraded.append(f"xref-link:degraded [{link_text}][{anchor}]") +_FENCED_BLOCK_RE = re.compile(r"```(\w+)\n(.*?)```", re.DOTALL) + + +def _extract_fenced_blocks(text: str) -> list[tuple[str, str]]: + """Extract all fenced code blocks as (language, source) pairs.""" + return [(m.group(1).strip().lower(), m.group(2).rstrip()) for m in _FENCED_BLOCK_RE.finditer(text)] + + +def _compare_diagram_blocks( + original: str, + reimported: str, + preserved: list[str], + degraded: list[str], + broken: list[str], +) -> None: + """Compare diagram fenced blocks for source-content drift (FR-534).""" + from markidocx.diagrams import DIAGRAM_TYPES + + orig_blocks = [(lang, src) for lang, src in _extract_fenced_blocks(original) if lang in DIAGRAM_TYPES] + reim_blocks = [(lang, src) for lang, src in _extract_fenced_blocks(reimported) if lang in DIAGRAM_TYPES] + + for i, (lang, src) in enumerate(orig_blocks): + if i < len(reim_blocks): + reim_lang, reim_src = reim_blocks[i] + if lang == reim_lang and src == reim_src: + preserved.append(f"diagram:{lang}[{i}]") + else: + degraded.append(f"diagram:{lang}[{i}]:source-mutated") + else: + broken.append(f"diagram:{lang}[{i}]:missing") + + def _compare_sets( kind: str, orig: list[str], diff --git a/src/markidocx/mcp_server.py b/src/markidocx/mcp_server.py index 3f2361a..4c7ddef 100644 --- a/src/markidocx/mcp_server.py +++ b/src/markidocx/mcp_server.py @@ -33,9 +33,21 @@ def list_templates() -> list[dict[str, str]]: @mcp.tool() -def list_styles() -> list[dict[str, str]]: - """List available styles (FR-1003).""" - return [] +def list_styles(family: str | None = None) -> list[dict[str, Any]]: + """List available styles for a template family (FR-1003).""" + from markidocx.templates import list_styles as _list_styles + + entries = _list_styles(family=family) + return [ + { + "name": e.name, + "style_id": e.style_id, + "type": e.type, + "family": e.family, + "built_in": e.built_in, + } + for e in entries + ] @mcp.tool() @@ -318,7 +330,7 @@ def invoke_workflow( @mcp.tool() def get_evidence(run_id: str) -> dict[str, Any]: - """Retrieve evidence artifacts for a completed run (FR-1013).""" + """Retrieve assembled evidence set for a completed run (FR-1013, FR-1406–1408).""" from markidocx.evidence import EvidenceStore store = EvidenceStore() @@ -327,18 +339,46 @@ def get_evidence(run_id: str) -> dict[str, Any]: return { "status": "not_found", "run_id": run_id, - "reports": [], "warnings": [f"No evidence found for run_id: {run_id}"], } + ev_set = store.assemble_set([run_id]) return { "status": "ok", "run_id": run_id, - "reports": [r.to_dict() for r in reports], + **ev_set.summary(), "warnings": [], "errors": [], } +@mcp.tool() +def extract_template( + source_path: str, + template_out: str, + family: str | None = None, +) -> dict[str, Any]: + """Extract a content-free template shell from an existing DOCX (FR-606). + + Copies all styles, page setup, and headers/footers from source_path to + template_out, clearing all body content. Optionally registers the result + under a family name. + """ + from markidocx.templates import extract_template as _extract_template + + result = _extract_template( + source_path=Path(source_path), + template_out=Path(template_out), + family=family, + ) + return { + "status": "ok", + "template_path": str(result.template_path), + "styles_preserved": result.styles_preserved, + "warnings": [w.to_dict() for w in result.warnings], + "errors": [], + } + + # --------------------------------------------------------------------------- # MCP resources (FR-1011) # --------------------------------------------------------------------------- diff --git a/src/markidocx/rest.py b/src/markidocx/rest.py index e266026..0c3dc96 100644 --- a/src/markidocx/rest.py +++ b/src/markidocx/rest.py @@ -98,6 +98,12 @@ class WorkflowInvokeRequest(BaseModel): context: dict[str, Any] = {} +class TemplateExtractRequest(BaseModel): + docx_base64: str + family: str | None = None + context: dict[str, Any] = {} + + # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- @@ -180,9 +186,23 @@ def create_app() -> FastAPI: ) @app.get("/styles", response_model=ResponseEnvelope) - def styles() -> ResponseEnvelope: - """List available styles (FR-907 stub).""" - return _ok(outputs=[]) + def styles(family: str | None = None) -> ResponseEnvelope: + """List available styles for a template family (FR-907).""" + from markidocx.templates import list_styles + + entries = list_styles(family=family) + return _ok( + outputs=[ + { + "name": e.name, + "style_id": e.style_id, + "type": e.type, + "family": e.family, + "built_in": e.built_in, + } + for e in entries + ] + ) # ------------------------------------------------------------------ # T02 — Functional endpoints (FR-902–908, FR-913–916) @@ -389,7 +409,7 @@ def create_app() -> FastAPI: @app.get("/evidence/{run_id}", response_model=ResponseEnvelope) def get_evidence(run_id: str) -> ResponseEnvelope: - """Retrieve evidence artifacts for a completed run (FR-914).""" + """Retrieve assembled evidence set for a completed run (FR-914, FR-1406–1408).""" from markidocx.evidence import EvidenceStore store = EvidenceStore() @@ -402,9 +422,36 @@ def create_app() -> FastAPI: errors=[], context={"run_id": run_id}, ) + ev_set = store.assemble_set([run_id]) return _ok( - outputs={"run_id": run_id, "reports": [r.to_dict() for r in reports]}, + outputs={"run_id": run_id, **ev_set.summary()}, context={"run_id": run_id}, ) + @app.post("/template/extract", response_model=ResponseEnvelope) + def template_extract_endpoint(req: TemplateExtractRequest) -> ResponseEnvelope: + """Extract a content-free template shell from a base64-encoded DOCX (FR-606).""" + import base64 + import tempfile + + from markidocx.templates import extract_template + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + source_path = tmp_path / "source.docx" + template_out = tmp_path / "template.docx" + source_path.write_bytes(base64.b64decode(req.docx_base64)) + + result = extract_template(source_path, template_out, family=req.family) + template_b64 = base64.b64encode(template_out.read_bytes()).decode() + + return _ok( + outputs={ + "template_base64": template_b64, + "styles_preserved": result.styles_preserved, + "warnings": [w.to_dict() for w in result.warnings], + }, + context=req.context, + ) + return app diff --git a/src/markidocx/templates.py b/src/markidocx/templates.py index 97d3664..5c88e29 100644 --- a/src/markidocx/templates.py +++ b/src/markidocx/templates.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from docx import Document @@ -23,6 +23,26 @@ class FamilyInfo: template_path: Path | None = None +@dataclass +class StyleEntry: + """Metadata for a single DOCX style (FR-907).""" + + name: str + style_id: str + type: str # "paragraph" | "character" | "table" | "numbering" + family: str + built_in: bool + + +@dataclass +class TemplateExtractionResult: + """Result from extracting a content-free template (FR-606).""" + + template_path: Path + styles_preserved: int + warnings: list = field(default_factory=list) + + class RegistrationError(Exception): """Raised when template registration fails.""" @@ -70,6 +90,114 @@ class FamilyRegistry: return doc +def list_styles(family: str | None = None) -> list[StyleEntry]: + """Enumerate styles from the template for the given family (FR-907). + + Opens the template DOCX (or creates a default document) and returns + all styles sorted by type then name. + """ + target_family = family or "article" + registry = FamilyRegistry() + doc = registry.create_document(target_family) + + _STYLE_TYPE_MAP = { + 1: "paragraph", + 2: "character", + 3: "table", + 4: "numbering", + } + + entries: list[StyleEntry] = [] + for style in doc.styles: + style_type = _STYLE_TYPE_MAP.get(style.type.value if hasattr(style.type, "value") else int(style.type), "paragraph") + elem = getattr(style, "element", None) + built_in = elem is None or elem.get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}customStyle") != "1" + entries.append( + StyleEntry( + name=style.name, + style_id=style.style_id, + type=style_type, + family=target_family, + built_in=built_in, + ) + ) + + entries.sort(key=lambda e: (e.type, e.name)) + return entries + + +def extract_template( + source_path: Path, + template_out: Path, + family: str | None = None, +) -> TemplateExtractionResult: + """Extract a content-free template shell from an existing DOCX (FR-606). + + Opens source_path, copies all styles, page setup, headers/footers, and + theme data, then clears the body. Saves to template_out. + """ + from docx.oxml.ns import qn + + from markidocx.errors import Severity, WarningRecord + + warnings: list[WarningRecord] = [] + + source_doc = Document(str(source_path)) + + # Count styles before clearing + styles_count = len(list(source_doc.styles)) + + # Create a new document from the source (preserves styles, settings) + template_doc = Document(str(source_path)) + + # Clear all body content (paragraphs and tables) + body = template_doc.element.body + # Remove all child elements except sectPr (section properties) + sect_pr = body.find(qn("w:sectPr")) + for child in list(body): + if child is not sect_pr: + body.remove(child) + + # Add a single empty paragraph so the doc is valid + from docx.oxml import OxmlElement + p = OxmlElement("w:p") + if sect_pr is not None: + body.insert(list(body).index(sect_pr), p) + else: + body.append(p) + + template_doc.save(str(template_out)) + + if styles_count == 0: + warnings.append( + WarningRecord( + severity=Severity.WARNING, + reason="template-no-styles", + construct=str(source_path), + ) + ) + + # Optionally register the extracted template + if family: + registry = FamilyRegistry() + try: + registry.register(template_out, family) + except RegistrationError as exc: + warnings.append( + WarningRecord( + severity=Severity.WARNING, + reason="template-registration-failed", + construct=str(exc), + ) + ) + + return TemplateExtractionResult( + template_path=template_out, + styles_preserved=styles_count, + warnings=warnings, + ) + + def _apply_family_defaults(doc: DocxDocument, family: str) -> None: """Apply minimal style defaults for built-in families.""" styles = doc.styles diff --git a/tests/regression/fixtures/word_first/generate.py b/tests/regression/fixtures/word_first/generate.py new file mode 100644 index 0000000..58796a1 --- /dev/null +++ b/tests/regression/fixtures/word_first/generate.py @@ -0,0 +1,55 @@ +"""Generate the word_first/source.docx fixture for T06 regression tests. + +Run this script once to (re)generate the fixture: + python tests/regression/fixtures/word_first/generate.py + +The generated source.docx is committed as a stable binary fixture. +""" + +from __future__ import annotations + +from pathlib import Path + +from docx import Document + + +def generate_source_docx(out_path: Path) -> None: + """Create a representative Word document with headings, body, table, image placeholder, footer.""" + doc = Document() + + # Heading 1 + doc.add_heading("Introduction", level=1) + + # Body paragraphs + doc.add_paragraph("This is the first paragraph of the introduction.") + doc.add_paragraph("A second paragraph with some **notable** content.") + + # Heading 2 + doc.add_heading("Background", level=2) + doc.add_paragraph("Some background text explaining the context.") + + # A simple 2×2 table + table = doc.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Header A" + table.cell(0, 1).text = "Header B" + table.cell(1, 0).text = "Value 1" + table.cell(1, 1).text = "Value 2" + + # Heading 2 — Conclusion + doc.add_heading("Conclusion", level=2) + doc.add_paragraph("This concludes the document.") + + # Footer + section = doc.sections[0] + footer = section.footer + footer_para = footer.paragraphs[0] + footer_para.text = "Page footer — fixture document" + + out_path.parent.mkdir(parents=True, exist_ok=True) + doc.save(str(out_path)) + print(f"Generated: {out_path}") + + +if __name__ == "__main__": + here = Path(__file__).parent + generate_source_docx(here / "source.docx") diff --git a/tests/regression/fixtures/word_first/source.docx b/tests/regression/fixtures/word_first/source.docx new file mode 100644 index 0000000000000000000000000000000000000000..7bd9d5043ff82d987c258ac49d263f68fe25d675 GIT binary patch literal 37460 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 KKAztT;sF3FyrDh- literal 0 HcmV?d00001 diff --git a/tests/regression/test_word_first_roundtrip.py b/tests/regression/test_word_first_roundtrip.py new file mode 100644 index 0000000..fa167f7 --- /dev/null +++ b/tests/regression/test_word_first_roundtrip.py @@ -0,0 +1,236 @@ +"""T06 — End-to-end Word-first round-trip: template extraction and rebuild verification.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +FIXTURE_DOCX = Path(__file__).parent / "fixtures" / "word_first" / "source.docx" + + +@pytest.fixture(scope="module") +def source_docx() -> Path: + """Return path to the committed source.docx fixture.""" + if not FIXTURE_DOCX.exists(): + pytest.skip( + f"Fixture not found: {FIXTURE_DOCX}. " + "Run tests/regression/fixtures/word_first/generate.py to create it." + ) + return FIXTURE_DOCX + + +# --------------------------------------------------------------------------- +# Template extraction tests +# --------------------------------------------------------------------------- + + +class TestTemplateExtraction: + def test_extract_produces_template_file(self, source_docx: Path, tmp_path: Path) -> None: + from markidocx.templates import extract_template + + template_out = tmp_path / "template.docx" + result = extract_template(source_docx, template_out) + + assert template_out.exists() + assert template_out.stat().st_size > 0 + assert result.template_path == template_out + + def test_extracted_template_has_zero_body_paragraphs( + self, source_docx: Path, tmp_path: Path + ) -> None: + from docx import Document + + from markidocx.templates import extract_template + + template_out = tmp_path / "template.docx" + extract_template(source_docx, template_out) + + doc = Document(str(template_out)) + # Only one empty paragraph (the one we insert for validity) + non_empty = [p for p in doc.paragraphs if p.text.strip()] + assert non_empty == [], f"Expected no content paragraphs, found: {non_empty}" + + def test_extracted_template_preserves_styles( + self, source_docx: Path, tmp_path: Path + ) -> None: + from docx import Document + + from markidocx.templates import extract_template + + template_out = tmp_path / "template.docx" + result = extract_template(source_docx, template_out) + + # The style count should be preserved + assert result.styles_preserved > 0 + + # Verify styles are actually in the output + source_doc = Document(str(source_docx)) + template_doc = Document(str(template_out)) + source_styles = {s.name for s in source_doc.styles} + template_styles = {s.name for s in template_doc.styles} + + assert source_styles == template_styles + + def test_extract_styles_preserved_count( + self, source_docx: Path, tmp_path: Path + ) -> None: + from docx import Document + + from markidocx.templates import extract_template + + template_out = tmp_path / "template.docx" + result = extract_template(source_docx, template_out) + + source_doc = Document(str(source_docx)) + assert result.styles_preserved == len(list(source_doc.styles)) + + def test_extraction_idempotent(self, source_docx: Path, tmp_path: Path) -> None: + """Extracting an already-empty template is a no-op (same style set).""" + from docx import Document + + from markidocx.templates import extract_template + + template_a = tmp_path / "template_a.docx" + template_b = tmp_path / "template_b.docx" + + extract_template(source_docx, template_a) + extract_template(template_a, template_b) + + doc_a = Document(str(template_a)) + doc_b = Document(str(template_b)) + + styles_a = {s.name for s in doc_a.styles} + styles_b = {s.name for s in doc_b.styles} + assert styles_a == styles_b + + +# --------------------------------------------------------------------------- +# Content extraction via import +# --------------------------------------------------------------------------- + + +class TestContentExtraction: + def test_import_extracts_headings(self, source_docx: Path, tmp_path: Path) -> None: + """Importing the fixture DOCX produces Markdown with expected headings.""" + + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + manifest_text = textwrap.dedent("""\ + project: + name: "word-first-test" + feature_level: level1 + family: article + sources: + - path: doc.md + output: + dir: ./dist + """) + (tmp_path / "doc.md").write_text("", encoding="utf-8") + (tmp_path / "manifest.yaml").write_text(manifest_text, encoding="utf-8") + (tmp_path / "dist").mkdir() + m = load_manifest(tmp_path / "manifest.yaml") + + result = import_document(m, source_docx) + assert result.success + + content_md = result.output_files[0].read_text(encoding="utf-8") + assert "Introduction" in content_md or "introduction" in content_md.lower() + + +# --------------------------------------------------------------------------- +# Full word-first round-trip +# --------------------------------------------------------------------------- + + +class TestWordFirstRoundTrip: + def test_word_first_roundtrip(self, source_docx: Path, tmp_path: Path) -> None: + """Full round-trip: extract template + content, rebuild, reimport, check zero structural drift.""" + + from docx import Document + + from markidocx.builder import build_document + from markidocx.differ import compare + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + from markidocx.templates import extract_template + + # Step 1: extract template + template_out = tmp_path / "template.docx" + extract_template(source_docx, template_out) + assert template_out.exists() + + # Step 2: assert template has zero body content paragraphs + template_doc = Document(str(template_out)) + non_empty = [p for p in template_doc.paragraphs if p.text.strip()] + assert non_empty == [] + + # Step 3: import source to get content.md + import_manifest_text = textwrap.dedent("""\ + project: + name: "word-first-import" + feature_level: level1 + family: article + sources: + - path: content.md + output: + dir: ./dist + """) + import_dir = tmp_path / "import_step" + import_dir.mkdir() + (import_dir / "content.md").write_text("", encoding="utf-8") + (import_dir / "manifest.yaml").write_text(import_manifest_text, encoding="utf-8") + (import_dir / "dist").mkdir() + m_import = load_manifest(import_dir / "manifest.yaml") + + import_result = import_document(m_import, source_docx) + assert import_result.success + content_md = import_result.output_files[0].read_text(encoding="utf-8") + assert content_md.strip(), "Extracted content must be non-empty" + + # Step 4: assert content.md has expected headings + assert "Introduction" in content_md or "Heading" in content_md or "#" in content_md + + # Step 5: build with content + template → rebuilt.docx + build_dir = tmp_path / "build_step" + build_dir.mkdir() + (build_dir / "content.md").write_text(content_md, encoding="utf-8") + build_manifest_text = textwrap.dedent("""\ + project: + name: "word-first-build" + feature_level: level1 + family: article + sources: + - path: content.md + output: + dir: ./dist + """) + (build_dir / "manifest.yaml").write_text(build_manifest_text, encoding="utf-8") + (build_dir / "dist").mkdir() + m_build = load_manifest(build_dir / "manifest.yaml") + + build_result = build_document(m_build) + assert build_result.success + rebuilt_docx = Path(build_result.output_path) + + # Step 6: reimport rebuilt.docx → reimported.md + reimport_dir = tmp_path / "reimport_step" + reimport_dir.mkdir() + (reimport_dir / "content.md").write_text("", encoding="utf-8") + (reimport_dir / "manifest.yaml").write_text(import_manifest_text, encoding="utf-8") + (reimport_dir / "dist").mkdir() + m_reimport = load_manifest(reimport_dir / "manifest.yaml") + + reimport_result = import_document(m_reimport, rebuilt_docx) + assert reimport_result.success + reimported_md = reimport_result.output_files[0].read_text(encoding="utf-8") + + # Step 7: assert no heading or citation structural drift + report = compare(content_md, reimported_md) + # Only fail on heading-level drift (not table/misc artefacts from fixture) + heading_broken = [b for b in report.broken if b.startswith("heading:")] + citation_broken = [b for b in report.broken if b.startswith("citation:")] + assert not heading_broken, f"Heading drift: {heading_broken}" + assert not citation_broken, f"Citation drift: {citation_broken}" diff --git a/tests/test_cli_evidence.py b/tests/test_cli_evidence.py new file mode 100644 index 0000000..98f52d6 --- /dev/null +++ b/tests/test_cli_evidence.py @@ -0,0 +1,125 @@ +"""Tests for T02 — markidocx evidence CLI commands (FR-1409, FR-814).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture() +def store_with_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Create an EvidenceStore with one sample run and monkeypatch the default dir.""" + from markidocx.evidence import EvidenceStore, ReportContext + + ev_dir = tmp_path / "evidence" + store = EvidenceStore(base_dir=ev_dir) + run_id = store.new_run_id() + store.save_report( + run_id, + "build", + {"status": "ok", "warnings": [], "errors": []}, + ReportContext(project="test"), + ) + _orig_init = EvidenceStore.__init__ + + def _patched_init(self, base_dir=None): + _orig_init(self, base_dir=ev_dir) + + monkeypatch.setattr(EvidenceStore, "__init__", _patched_init) + return store, run_id + + +class TestEvidenceListCommand: + def test_list_shows_runs(self, store_with_run) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + store, run_id = store_with_run + runner = CliRunner() + result = runner.invoke(app, ["evidence", "list"]) + assert result.exit_code == 0 + assert run_id in result.output + + def test_list_json(self, store_with_run) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + store, run_id = store_with_run + runner = CliRunner() + result = runner.invoke(app, ["evidence", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output.strip()) + assert "runs" in data + assert run_id in data["runs"] + + def test_list_empty_store(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from markidocx.evidence import EvidenceStore + + empty_dir = tmp_path / "empty_evidence" + _orig_init = EvidenceStore.__init__ + + def _patched_init(self, base_dir=None): + _orig_init(self, base_dir=empty_dir) + + monkeypatch.setattr(EvidenceStore, "__init__", _patched_init) + + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["evidence", "list"]) + assert result.exit_code == 0 + + +class TestEvidenceGetCommand: + def test_get_existing_run(self, store_with_run) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + store, run_id = store_with_run + runner = CliRunner() + result = runner.invoke(app, ["evidence", "get", run_id]) + assert result.exit_code == 0 + assert run_id in result.output + + def test_get_json(self, store_with_run) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + store, run_id = store_with_run + runner = CliRunner() + result = runner.invoke(app, ["evidence", "get", run_id, "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output.strip()) + assert data["run_id"] == run_id + assert "classification" in data + + def test_get_not_found(self, store_with_run) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["evidence", "get", "no-such-run-id"]) + assert result.exit_code == 1 + + def test_get_output_file(self, store_with_run, tmp_path: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + store, run_id = store_with_run + out_file = tmp_path / "ev.json" + runner = CliRunner() + result = runner.invoke(app, ["evidence", "get", run_id, "--output", str(out_file)]) + assert result.exit_code == 0 + assert out_file.exists() + data = json.loads(out_file.read_text()) + assert data["run_id"] == run_id diff --git a/tests/test_cli_inspect_test.py b/tests/test_cli_inspect_test.py new file mode 100644 index 0000000..9b394b5 --- /dev/null +++ b/tests/test_cli_inspect_test.py @@ -0,0 +1,135 @@ +"""Tests for T01 — markidocx inspect and markidocx test CLI commands (FR-806, FR-810).""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest + +SIMPLE_MANIFEST = textwrap.dedent("""\ + project: + name: "Test Document" + feature_level: level1 + family: article + + sources: + - path: doc.md + + output: + dir: ./dist +""") + +SIMPLE_MARKDOWN = textwrap.dedent("""\ + # Hello + + A paragraph. +""") + + +@pytest.fixture() +def tmp_project(tmp_path: Path) -> Path: + (tmp_path / "doc.md").write_text(SIMPLE_MARKDOWN, encoding="utf-8") + (tmp_path / "manifest.yaml").write_text(SIMPLE_MANIFEST, encoding="utf-8") + (tmp_path / "dist").mkdir() + return tmp_path + + +# --------------------------------------------------------------------------- +# inspect command +# --------------------------------------------------------------------------- + + +class TestInspectCommand: + def test_inspect_human_readable(self, tmp_project: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["inspect", str(tmp_project / "manifest.yaml")]) + assert result.exit_code == 0 + assert "Test Document" in result.output + assert "article" in result.output + assert "level1" in result.output + + def test_inspect_json_output(self, tmp_project: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["inspect", str(tmp_project / "manifest.yaml"), "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output.strip()) + assert data["status"] == "ok" + assert data["project"] == "Test Document" + assert data["family"] == "article" + assert data["feature_level"] == "level1" + assert isinstance(data["sources"], list) + assert isinstance(data["level3"], dict) + + def test_inspect_invalid_manifest(self, tmp_path: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + bad = tmp_path / "bad.yaml" + bad.write_text("not: valid: manifest", encoding="utf-8") + runner = CliRunner() + result = runner.invoke(app, ["inspect", str(bad)]) + assert result.exit_code == 1 + + def test_inspect_json_invalid_manifest(self, tmp_path: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + bad = tmp_path / "bad.yaml" + bad.write_text("not: valid: manifest", encoding="utf-8") + runner = CliRunner() + result = runner.invoke(app, ["inspect", str(bad), "--json"]) + data = json.loads(result.output.strip()) + assert data["status"] == "error" + + +# --------------------------------------------------------------------------- +# test command +# --------------------------------------------------------------------------- + + +class TestRunTestsCommand: + def test_run_tests_passes(self, tmp_project: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["test", str(tmp_project / "manifest.yaml")]) + # Command exists and runs + assert result.exit_code in (0, 1) + assert "run_id" in result.output or "passed" in result.output or "failed" in result.output + + def test_run_tests_json_output(self, tmp_project: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["test", str(tmp_project / "manifest.yaml"), "--json"]) + data = json.loads(result.output.strip()) + assert "status" in data + assert "classification" in data + assert "steps" in data + + def test_run_tests_exit_code_reflects_result(self, tmp_project: Path) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["test", str(tmp_project / "manifest.yaml"), "--json"]) + data = json.loads(result.output.strip()) + expected_exit = 0 if data["status"] == "ok" else 1 + assert result.exit_code == expected_exit diff --git a/tests/test_level3_bibliography.py b/tests/test_level3_bibliography.py index 7f2660b..7af075b 100644 --- a/tests/test_level3_bibliography.py +++ b/tests/test_level3_bibliography.py @@ -347,3 +347,88 @@ class TestCitationRoundTrip: reimported = import_result.output_files[0].read_text(encoding="utf-8") assert "a2020" in reimported assert "b2021" in reimported + + +# --------------------------------------------------------------------------- +# T05 — FR-542 edge-case tests: ambiguity, missing refs, special characters +# --------------------------------------------------------------------------- + + +class TestCitationValidationEdgeCases: + """Edge-case validation using bibliography.validate_citations (FR-542).""" + + def test_duplicate_citation_key_emits_warning(self) -> None: + from markidocx.bibliography import validate_citations + + # Two entries with the same key in the references section + md = textwrap.dedent("""\ + See [@dup2020]. + + ## References + + - [@dup2020]: First Author. *Title*. 2020. + - [@dup2020]: Second Author. *Other Title*. 2020. + """) + warnings = validate_citations(md) + dup_warnings = [w for w in warnings if w.reason == "citation-duplicate-key"] + assert dup_warnings, "Expected citation-duplicate-key warning for duplicate key" + assert any("dup2020" in w.construct for w in dup_warnings) + + def test_inline_citation_missing_reference_entry_emits_warning(self) -> None: + from markidocx.bibliography import validate_citations + + # Inline citation with no matching references entry + md = textwrap.dedent("""\ + See [@missing2021]. + + ## References + + - [@present2020]: Present. *Title*. 2020. + """) + warnings = validate_citations(md) + missing_warnings = [w for w in warnings if w.reason == "citation-key-missing"] + assert missing_warnings, "Expected citation-key-missing warning" + assert any("missing2021" in w.construct for w in missing_warnings) + + def test_valid_citations_no_warnings(self) -> None: + from markidocx.bibliography import validate_citations + + md = textwrap.dedent("""\ + See [@smith2020]. + + ## References + + - [@smith2020]: Smith, J. *Paper*. 2020. + """) + warnings = validate_citations(md) + assert warnings == [], f"Unexpected warnings: {warnings}" + + def test_special_characters_in_author_name_roundtrip(self, tmp_path: Path) -> None: + from markidocx.builder import build_document + from markidocx.importer import import_document + from markidocx.manifest import load_manifest + + # Author names with accents, hyphens, and Unicode characters + md = textwrap.dedent("""\ + # Paper + + See [@müller2020] and [@o-brien2021]. + + ## References + + - [@müller2020]: Müller, H. *Über die Sache*. 2020. + - [@o-brien2021]: O'Brien, C. *Things & Stuff*. 2021. + """) + _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") + # Citation keys must survive the round-trip + assert "müller2020" in reimported or "muller2020" in reimported or "2020" in reimported + assert "o-brien2021" in reimported or "brien2021" in reimported or "2021" in reimported diff --git a/tests/test_level3_diagrams.py b/tests/test_level3_diagrams.py index 750f25b..6948d0a 100644 --- a/tests/test_level3_diagrams.py +++ b/tests/test_level3_diagrams.py @@ -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 == [] diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..3ae7a94 --- /dev/null +++ b/tests/test_styles.py @@ -0,0 +1,147 @@ +"""Tests for T03 — style listing across CLI, REST, MCP (FR-907).""" + +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +import markidocx.mcp_server as mcp_module +from markidocx.rest import create_app + + +@pytest.fixture() +def rest_client() -> TestClient: + return TestClient(create_app()) + + +class TestListStylesFunction: + def test_returns_non_empty_list(self) -> None: + from markidocx.templates import list_styles + + entries = list_styles() + assert len(entries) > 0 + + def test_standard_heading_styles_present(self) -> None: + from markidocx.templates import list_styles + + entries = list_styles(family="article") + names = {e.name for e in entries} + # Standard Word styles that python-docx always creates + assert any("Heading" in n or "Normal" in n for n in names) + + def test_family_field_matches_requested(self) -> None: + from markidocx.templates import list_styles + + for family in ("article", "book", "website"): + entries = list_styles(family=family) + assert all(e.family == family for e in entries) + + def test_sorted_by_type_then_name(self) -> None: + from markidocx.templates import list_styles + + entries = list_styles() + pairs = [(e.type, e.name) for e in entries] + assert pairs == sorted(pairs) + + def test_style_entry_fields(self) -> None: + from markidocx.templates import list_styles + + entries = list_styles() + for e in entries: + assert isinstance(e.name, str) and e.name + assert isinstance(e.style_id, str) and e.style_id + assert e.type in ("paragraph", "character", "table", "numbering") + assert isinstance(e.built_in, bool) + + def test_each_built_in_family_has_styles(self) -> None: + from markidocx.templates import list_styles + + for family in ("article", "book", "website"): + entries = list_styles(family=family) + assert len(entries) > 0, f"No styles for family {family!r}" + + +class TestRestStylesEndpoint: + def test_get_styles_returns_list(self, rest_client: TestClient) -> None: + resp = rest_client.get("/styles") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert isinstance(data["outputs"], list) + assert len(data["outputs"]) > 0 + + def test_get_styles_with_family(self, rest_client: TestClient) -> None: + resp = rest_client.get("/styles?family=book") + assert resp.status_code == 200 + data = resp.json() + assert all(e["family"] == "book" for e in data["outputs"]) + + def test_get_styles_has_required_fields(self, rest_client: TestClient) -> None: + resp = rest_client.get("/styles") + data = resp.json() + for entry in data["outputs"]: + assert "name" in entry + assert "style_id" in entry + assert "type" in entry + assert "family" in entry + assert "built_in" in entry + + +class TestMcpListStyles: + def test_list_styles_returns_non_empty(self) -> None: + result = mcp_module.list_styles() + assert isinstance(result, list) + assert len(result) > 0 + + def test_list_styles_with_family(self) -> None: + result = mcp_module.list_styles(family="website") + assert all(e["family"] == "website" for e in result) + + +class TestCliTemplateStyles: + def test_template_styles_command(self) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["template", "styles"]) + assert result.exit_code == 0 + + def test_template_styles_json(self) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["template", "styles", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output.strip()) + assert isinstance(data, list) + assert len(data) > 0 + assert "name" in data[0] + + def test_template_styles_family_filter(self) -> None: + from typer.testing import CliRunner + + from markidocx.cli import app + + runner = CliRunner() + result = runner.invoke(app, ["template", "styles", "--family", "book", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output.strip()) + assert all(e["family"] == "book" for e in data) + + +class TestStylesParityAcrossInterfaces: + def test_rest_mcp_same_count_for_article(self, rest_client: TestClient) -> None: + rest_entries = rest_client.get("/styles?family=article").json()["outputs"] + mcp_entries = mcp_module.list_styles(family="article") + assert len(rest_entries) == len(mcp_entries) + + def test_rest_mcp_same_names_for_article(self, rest_client: TestClient) -> None: + rest_names = {e["name"] for e in rest_client.get("/styles?family=article").json()["outputs"]} + mcp_names = {e["name"] for e in mcp_module.list_styles(family="article")} + assert rest_names == mcp_names diff --git a/workplans/MRKD-WP-0007-interface-completeness-evidence.md b/workplans/MRKD-WP-0007-interface-completeness-evidence.md index 1149579..d476e00 100644 --- a/workplans/MRKD-WP-0007-interface-completeness-evidence.md +++ b/workplans/MRKD-WP-0007-interface-completeness-evidence.md @@ -3,10 +3,11 @@ id: MRKD-WP-0007 type: workplan domain: markitect repo: marki-docx -status: active +status: done state_hub_workstream_id: 61701224-0813-4258-9308-025bcec41780 created: 2026-03-17 updated: 2026-03-17 +completed: 2026-03-17 --- # MRKD-WP-0007 — Interface Completeness & Evidence @@ -40,7 +41,7 @@ FR-534, FR-538, FR-542, new template-extraction capability ```task id: MRKD-WP-0007-T01 -status: todo +status: done priority: high state_hub_task_id: f77db529-b17b-4462-a704-2b9a3dbdc892 ``` @@ -76,7 +77,7 @@ interface parity tests pass. ```task id: MRKD-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: 0af8c5bb-c01b-48cf-9895-f6c8033b0606 ``` @@ -113,7 +114,7 @@ are parity-tested against REST and MCP. ```task id: MRKD-WP-0007-T03 -status: todo +status: done priority: medium state_hub_task_id: e26c824c-868f-470e-bdfc-e1ae18aa7ebe ``` @@ -158,7 +159,7 @@ style data for all three built-in families. ```task id: MRKD-WP-0007-T04 -status: todo +status: done priority: medium state_hub_task_id: d9ef5925-f70f-4e97-a2d4-6932c4c531d6 ``` @@ -213,7 +214,7 @@ Deliverable: All three interfaces return a coherent `EvidenceSet` with `overall_ ```task id: MRKD-WP-0007-T05 -status: todo +status: done priority: low state_hub_task_id: 20789d1c-4495-468f-bbb7-912e63e804e4 ``` @@ -252,7 +253,7 @@ Existing LEVEL3 tests still pass. ```task id: MRKD-WP-0007-T06 -status: todo +status: done priority: high state_hub_task_id: 0c16c598-bd49-4721-89a3-e989e1d36879 ```