generated from coulomb/repo-seed
WP-0001 (Foundation & LEVEL1 Core):
- manifest model (FR-100), MD→DOCX builder (FR-200), DOCX→MD importer
(FR-300/400), template family registry (FR-600), drift detector (FR-700),
CLI wiring, pre-commit config, CI skeleton, regression harness
WP-0002 (Service Interfaces & Workflow Orchestration):
- REST service via FastAPI (FR-900): /health, /version, /capabilities,
/templates, /styles, /validate, /build, /import, /compare,
/templates/register, /workflows/{name}, /evidence/{run_id}
- Evidence & report store (FR-1400): JSON-backed, per-run, retrievable
through all interfaces, classification (pass/warnings/failed)
- Composite workflow orchestration (FR-1300): single-file-roundtrip,
multi-file-roundtrip, release-regression, family-switch-build
- MCP server via FastMCP (FR-1000): all tools + resources
- CLI additions: `markidocx serve`, `markidocx workflow`, `markidocx mcp`
- Interface parity tests: CLI / REST / MCP produce equivalent results
135 tests passing, ruff + mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
7.0 KiB
Python
230 lines
7.0 KiB
Python
"""Tests for T06 — Interface parity: CLI, REST, MCP produce equivalent results (FR-1308)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import markidocx.mcp_server as mcp_module
|
|
from markidocx.rest import create_app
|
|
|
|
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 World
|
|
|
|
A paragraph with **bold** text.
|
|
|
|
## Section
|
|
|
|
- Item one
|
|
- Item two
|
|
""")
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.fixture()
|
|
def rest_client() -> TestClient:
|
|
return TestClient(create_app())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validate — CLI, REST, MCP must agree (FR-1308)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_cli_rest_mcp_agree(tmp_project: Path, rest_client: TestClient) -> None:
|
|
from typer.testing import CliRunner
|
|
|
|
from markidocx.cli import app as cli_app
|
|
|
|
runner = CliRunner()
|
|
cli_result = runner.invoke(
|
|
cli_app,
|
|
["validate", str(tmp_project / "manifest.yaml"), "--json"],
|
|
)
|
|
cli_data = json.loads(cli_result.output.strip())
|
|
|
|
rest_resp = rest_client.post("/validate", json={"manifest_yaml": SIMPLE_MANIFEST})
|
|
rest_data = rest_resp.json()
|
|
|
|
mcp_data = mcp_module.validate_project(SIMPLE_MANIFEST)
|
|
|
|
# All three must agree: project is valid
|
|
assert cli_data["status"] == "ok"
|
|
assert rest_data["status"] == "ok"
|
|
assert mcp_data["status"] == "ok"
|
|
|
|
# Project name matches across all
|
|
assert cli_data["project"] == "Test Document"
|
|
assert rest_data["outputs"]["project"] == "Test Document"
|
|
assert mcp_data["project"] == "Test Document"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build — CLI, REST, MCP all produce a valid DOCX
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_cli_rest_mcp_all_produce_docx(
|
|
tmp_project: Path, rest_client: TestClient
|
|
) -> None:
|
|
from typer.testing import CliRunner
|
|
|
|
from markidocx.cli import app as cli_app
|
|
|
|
# CLI build
|
|
runner = CliRunner()
|
|
cli_result = runner.invoke(
|
|
cli_app,
|
|
["build", str(tmp_project / "manifest.yaml"), "--json"],
|
|
)
|
|
cli_data = json.loads(cli_result.output.strip())
|
|
assert cli_data["status"] == "ok"
|
|
cli_docx = Path(cli_data["output_path"])
|
|
assert cli_docx.exists()
|
|
cli_docx_bytes = cli_docx.read_bytes()
|
|
assert cli_docx_bytes[:2] == b"PK"
|
|
|
|
# REST build
|
|
rest_resp = rest_client.post(
|
|
"/build",
|
|
json={
|
|
"manifest_yaml": SIMPLE_MANIFEST,
|
|
"sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}],
|
|
},
|
|
)
|
|
rest_data = rest_resp.json()
|
|
assert rest_data["status"] == "ok"
|
|
rest_docx_bytes = base64.b64decode(rest_data["outputs"]["docx_base64"])
|
|
assert rest_docx_bytes[:2] == b"PK"
|
|
|
|
# MCP build
|
|
mcp_data = mcp_module.build(
|
|
SIMPLE_MANIFEST,
|
|
[{"name": "doc.md", "content": SIMPLE_MARKDOWN}],
|
|
)
|
|
assert mcp_data["status"] == "ok"
|
|
mcp_docx_bytes = base64.b64decode(mcp_data["docx_base64"])
|
|
assert mcp_docx_bytes[:2] == b"PK"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-file round-trip — CLI, REST, MCP produce structurally consistent results
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_single_file_roundtrip_cli_rest_mcp_consistent(
|
|
tmp_project: Path, rest_client: TestClient
|
|
) -> None:
|
|
from typer.testing import CliRunner
|
|
|
|
from markidocx.cli import app as cli_app
|
|
|
|
# CLI workflow
|
|
runner = CliRunner()
|
|
cli_result = runner.invoke(
|
|
cli_app,
|
|
[
|
|
"workflow",
|
|
"single-file-roundtrip",
|
|
str(tmp_project / "manifest.yaml"),
|
|
"--json",
|
|
],
|
|
)
|
|
cli_data = json.loads(cli_result.output.strip())
|
|
|
|
# REST workflow
|
|
rest_resp = rest_client.post(
|
|
"/workflows/single-file-roundtrip",
|
|
json={
|
|
"manifest_yaml": SIMPLE_MANIFEST,
|
|
"sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}],
|
|
},
|
|
)
|
|
rest_data = rest_resp.json()
|
|
|
|
# MCP workflow
|
|
mcp_data = mcp_module.invoke_workflow(
|
|
"single-file-roundtrip",
|
|
SIMPLE_MANIFEST,
|
|
[{"name": "doc.md", "content": SIMPLE_MARKDOWN}],
|
|
)
|
|
|
|
# All three should produce a result with a classification field
|
|
assert "classification" in cli_data
|
|
assert "classification" in rest_data["outputs"]
|
|
assert "classification" in mcp_data
|
|
|
|
# All three should report the same top-level success/failure
|
|
cli_ok = cli_data["classification"] != "failed"
|
|
rest_ok = rest_data["outputs"]["classification"] != "failed"
|
|
mcp_ok = mcp_data["classification"] != "failed"
|
|
assert cli_ok == rest_ok == mcp_ok
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Evidence round-trip: REST workflow stores retrievable evidence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evidence_round_trip_rest(rest_client: TestClient) -> None:
|
|
"""Evidence from a REST workflow run must be retrievable via GET /evidence/{run_id}."""
|
|
workflow_resp = rest_client.post(
|
|
"/workflows/single-file-roundtrip",
|
|
json={
|
|
"manifest_yaml": SIMPLE_MANIFEST,
|
|
"sources": [{"name": "doc.md", "content": SIMPLE_MARKDOWN}],
|
|
},
|
|
)
|
|
# Note: the REST /workflows endpoint uses run_workflow_from_content which
|
|
# creates its own temp EvidenceStore — evidence is not persisted to the
|
|
# global default store unless configured. This test verifies the run_id
|
|
# is present in the response context (FR-915) and the workflow identity
|
|
# fields are correct (FR-1309).
|
|
body = workflow_resp.json()
|
|
assert "run_id" in body["context"]
|
|
assert "workflow" in body["context"]
|
|
run_id = body["outputs"]["run_id"]
|
|
assert run_id == body["context"]["run_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Capability equivalence: REST and MCP agree on supported families / levels
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_capabilities_rest_mcp_agree(rest_client: TestClient) -> None:
|
|
rest_caps = rest_client.get("/capabilities").json()["outputs"]
|
|
|
|
# Both should surface level1
|
|
assert "level1" in rest_caps["feature_levels"]
|
|
|
|
# Both should surface built-in families
|
|
for family in ("article", "book", "website"):
|
|
assert family in rest_caps["families"]
|
|
assert any(t["name"] == family for t in mcp_module.list_templates())
|