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 0000000..7bd9d50 Binary files /dev/null and b/tests/regression/fixtures/word_first/source.docx differ 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 ```