feat: WP-0007 — Interface Completeness & Evidence

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

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

278 tests pass, ruff+mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 19:30:09 +00:00
parent 893b9fa57b
commit 9fe64bcd7f
16 changed files with 1537 additions and 19 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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],

View File

@@ -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-14061408)."""
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)
# ---------------------------------------------------------------------------

View File

@@ -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-902908, FR-913916)
@@ -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-14061408)."""
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

View File

@@ -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

View File

@@ -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")

Binary file not shown.

View File

@@ -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}"

125
tests/test_cli_evidence.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -345,3 +345,156 @@ class TestRendererDetection:
r2 = RendererResult(success=False)
assert not r2.success
assert r2.output_path is None
# ---------------------------------------------------------------------------
# T05 — FR-534 edge cases: diagram source mutation and empty source
# ---------------------------------------------------------------------------
class TestDiagramEdgeCases:
"""FR-534: diagram source mutation and empty source edge cases."""
def test_mutated_source_marker_classified_as_structural_drift(
self, tmp_path: Path, monkeypatch
) -> None:
"""Mutated source marker content is not silently dropped by the importer (FR-534).
We construct a DOCX with only a source-intent marker paragraph (simulating
what the renderer path produces) and verify that the importer uses the marker
to reconstruct the fenced block. We then check the differ sees the mutation.
"""
from docx import Document as DocxDoc
from docx.shared import Pt
from markidocx.diagrams import DIAGRAM_SOURCE_MARKER_PREFIX
from markidocx.differ import compare
from markidocx.importer import import_document
from markidocx.manifest import load_manifest
# Build a minimal DOCX with ONLY a source marker paragraph (no verbatim code block)
# — this simulates the rendered path where a PNG was embedded.
docx_path = tmp_path / "mutated.docx"
doc = DocxDoc()
# Add a heading so there is some structure
doc.add_heading("Test", level=1)
# Add source marker with MUTATED source (A-->C, not original A-->B)
marker_para = doc.add_paragraph(style="Normal")
marker_run = marker_para.add_run(
f"{DIAGRAM_SOURCE_MARKER_PREFIX}mermaid\ngraph TD\nA-->C"
)
marker_run.font.size = Pt(1)
doc.save(str(docx_path))
# Set up a manifest
_make_project(tmp_path, "")
m = load_manifest(tmp_path / "manifest.yaml")
import_result = import_document(m, docx_path)
assert import_result.success
reimported = import_result.output_files[0].read_text(encoding="utf-8")
# The mutated source (A-->C) must appear in the reimported content
assert "A-->C" in reimported, "Mutated diagram source was silently dropped"
# The differ between original (A-->B) and reimported (A-->C) should detect drift
original_md = "```mermaid\ngraph TD\nA-->B\n```"
report = compare(original_md, reimported)
assert report.has_drift, (
f"Differ should detect structural drift after source mutation. "
f"preserved={report.preserved}, degraded={report.degraded}, broken={report.broken}"
)
def test_empty_diagram_source_emits_warning(
self, tmp_path: Path, monkeypatch
) -> None:
"""A diagram block with empty source must produce a WarningRecord."""
import shutil
from markidocx.builder import build_document
from markidocx.manifest import load_manifest
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
# Empty mermaid block
md = "```mermaid\n\n```"
_make_project(tmp_path, md)
m = load_manifest(tmp_path / "manifest.yaml")
result = build_document(m)
assert result.success
# Any warning related to diagrams or empty source
all_reasons = [w.reason for w in result.warning_records]
# At minimum the processor-dependency warning fires since no renderer available
assert any("processor-dependency" in r or "diagram" in r or "empty" in r for r in all_reasons)
# ---------------------------------------------------------------------------
# T05 — FR-538: renderer version checking
# ---------------------------------------------------------------------------
class TestRendererVersionCheck:
"""FR-538: outdated renderer versions must emit WarningRecord."""
def test_supported_version_no_warning(self, monkeypatch) -> None:
import subprocess
import markidocx.diagrams as diag_mod
# Mock mmdc --version to return a supported version
def _fake_run(cmd, **kwargs):
class R:
stdout = "mermaid.js/10.4.0"
stderr = ""
returncode = 0
return R()
monkeypatch.setattr(subprocess, "run", _fake_run)
warnings: list = []
diag_mod.check_renderer_version("mmdc", warnings)
version_warnings = [w for w in warnings if w.reason == "renderer-version-unsupported"]
assert not version_warnings, "Should not warn for supported version"
def test_outdated_version_emits_warning(self, monkeypatch) -> None:
import subprocess
import markidocx.diagrams as diag_mod
# Mock mmdc --version to return an unsupported old version (8.x < 9.x minimum)
def _fake_run(cmd, **kwargs):
class R:
stdout = "mermaid.js/8.2.3"
stderr = ""
returncode = 0
return R()
monkeypatch.setattr(subprocess, "run", _fake_run)
warnings: list = []
diag_mod.check_renderer_version("mmdc", warnings)
version_warnings = [w for w in warnings if w.reason == "renderer-version-unsupported"]
assert version_warnings, "Expected renderer-version-unsupported warning for old version"
def test_unknown_renderer_no_crash(self, monkeypatch) -> None:
import markidocx.diagrams as diag_mod
warnings: list = []
# Unknown cmd — should silently return without crashing
diag_mod.check_renderer_version("unknown-renderer", warnings)
assert warnings == []
def test_subprocess_error_no_crash(self, monkeypatch) -> None:
import subprocess
import markidocx.diagrams as diag_mod
def _fake_run(*args, **kwargs):
raise OSError("command not found")
monkeypatch.setattr(subprocess, "run", _fake_run)
warnings: list = []
diag_mod.check_renderer_version("mmdc", warnings)
# Should not raise
assert warnings == []

147
tests/test_styles.py Normal file
View File

@@ -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

View File

@@ -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
```