generated from coulomb/repo-seed
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
55
tests/regression/fixtures/word_first/generate.py
Normal file
55
tests/regression/fixtures/word_first/generate.py
Normal 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")
|
||||
BIN
tests/regression/fixtures/word_first/source.docx
Normal file
BIN
tests/regression/fixtures/word_first/source.docx
Normal file
Binary file not shown.
236
tests/regression/test_word_first_roundtrip.py
Normal file
236
tests/regression/test_word_first_roundtrip.py
Normal 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
125
tests/test_cli_evidence.py
Normal 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
|
||||
135
tests/test_cli_inspect_test.py
Normal file
135
tests/test_cli_inspect_test.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
147
tests/test_styles.py
Normal 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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user