feat: WP-0001 + WP-0002 complete — LEVEL1 core + service interfaces

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>
This commit is contained in:
2026-03-16 07:46:31 +00:00
parent 42789cad1e
commit 1f3dddf7d6
30 changed files with 4158 additions and 26 deletions

212
tests/test_evidence.py Normal file
View File

@@ -0,0 +1,212 @@
"""Tests for T03 — Evidence & report storage (FR-1400)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from markidocx.evidence import EvidenceStore, ReportContext, RunReport
@pytest.fixture()
def store(tmp_path: Path) -> EvidenceStore:
return EvidenceStore(base_dir=tmp_path / "evidence")
# ---------------------------------------------------------------------------
# EvidenceStore basics (FR-1401FR-1404)
# ---------------------------------------------------------------------------
def test_new_run_id_is_unique(store: EvidenceStore) -> None:
ids = {store.new_run_id() for _ in range(10)}
assert len(ids) == 10
def test_save_and_get_report(store: EvidenceStore) -> None:
run_id = store.new_run_id()
data = {"status": "ok", "project": "Test", "warnings": [], "errors": []}
store.save_report(run_id, "validation", data)
report = store.get_report(run_id, "validation")
assert report is not None
assert report.run_id == run_id
assert report.report_type == "validation"
assert report.data["status"] == "ok"
def test_get_missing_report_returns_none(store: EvidenceStore) -> None:
assert store.get_report("nonexistent-id", "validation") is None
def test_save_report_writes_json_file(store: EvidenceStore, tmp_path: Path) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
path = tmp_path / "evidence" / run_id / "build.json"
assert path.exists()
content = json.loads(path.read_text())
assert content["report_type"] == "build"
def test_save_all_report_types(store: EvidenceStore) -> None:
run_id = store.new_run_id()
for rtype in ("validation", "build", "import", "drift"):
store.save_report(run_id, rtype, {"status": "ok", "warnings": [], "errors": []})
reports = store.list_reports(run_id)
types = {r.report_type for r in reports}
assert types == {"validation", "build", "import", "drift"}
# ---------------------------------------------------------------------------
# List and retrieve (FR-1409)
# ---------------------------------------------------------------------------
def test_list_runs_empty(store: EvidenceStore) -> None:
assert store.list_runs() == []
def test_list_runs_after_save(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "validation", {"status": "ok", "warnings": [], "errors": []})
assert run_id in store.list_runs()
def test_list_reports_empty_for_unknown_run(store: EvidenceStore) -> None:
assert store.list_reports("no-such-run") == []
def test_list_reports_returns_all(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "validation", {"status": "ok", "warnings": [], "errors": []})
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
reports = store.list_reports(run_id)
assert len(reports) == 2
# ---------------------------------------------------------------------------
# Traceability fields (FR-1410)
# ---------------------------------------------------------------------------
def test_report_context_stored_and_retrieved(store: EvidenceStore) -> None:
run_id = store.new_run_id()
ctx = ReportContext(project="TestDoc", family="article", feature_level="level1", workflow="single-file-roundtrip")
store.save_report(run_id, "validation", {"status": "ok", "warnings": [], "errors": []}, context=ctx)
report = store.get_report(run_id, "validation")
assert report is not None
assert report.context.project == "TestDoc"
assert report.context.family == "article"
assert report.context.feature_level == "level1"
assert report.context.workflow == "single-file-roundtrip"
def test_report_has_timestamp(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
report = store.get_report(run_id, "build")
assert report is not None
assert report.created_at # non-empty ISO timestamp
# ---------------------------------------------------------------------------
# EvidenceSet assembly (FR-1406FR-1408)
# ---------------------------------------------------------------------------
def test_assemble_set(store: EvidenceStore) -> None:
run1 = store.new_run_id()
run2 = store.new_run_id()
store.save_report(run1, "build", {"status": "ok", "warnings": [], "errors": []})
store.save_report(run2, "import", {"status": "ok", "warnings": [], "errors": []})
ev_set = store.assemble_set([run1, run2])
assert len(ev_set.reports) == 2
assert set(ev_set.run_ids) == {run1, run2}
def test_evidence_set_classification_pass(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
ev_set = store.assemble_set([run_id])
assert ev_set.classification == "pass"
def test_evidence_set_classification_pass_with_warnings(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": ["some warning"], "errors": []})
ev_set = store.assemble_set([run_id])
assert ev_set.classification == "pass-with-warnings"
def test_evidence_set_classification_failed(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "error", "warnings": [], "errors": ["oops"]})
ev_set = store.assemble_set([run_id])
assert ev_set.classification == "failed"
def test_evidence_set_composition(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "validation", {"status": "ok", "warnings": [], "errors": []})
ev_set = store.assemble_set([run_id])
comp = ev_set.composition
assert len(comp) == 1
assert comp[0]["run_id"] == run_id
assert comp[0]["type"] == "validation"
def test_evidence_set_summary_keys(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
ev_set = store.assemble_set([run_id])
summary = ev_set.summary()
assert "classification" in summary
assert "run_count" in summary
assert "report_count" in summary
assert "complete" in summary
assert "warnings_count" in summary
assert "composition" in summary
# ---------------------------------------------------------------------------
# Human-readable and machine-readable output (FR-1411, FR-1412)
# ---------------------------------------------------------------------------
def test_to_markdown(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
md = store.to_markdown(run_id)
assert run_id in md
assert "Build" in md
def test_to_json(store: EvidenceStore) -> None:
run_id = store.new_run_id()
store.save_report(run_id, "build", {"status": "ok", "warnings": [], "errors": []})
raw = store.to_json(run_id)
parsed = json.loads(raw)
assert parsed["run_id"] == run_id
assert len(parsed["reports"]) == 1
# ---------------------------------------------------------------------------
# RunReport round-trip serialisation
# ---------------------------------------------------------------------------
def test_run_report_to_from_dict() -> None:
ctx = ReportContext(project="P", family="article")
r = RunReport(
run_id="abc",
report_type="build",
data={"status": "ok", "warnings": [], "errors": []},
created_at="2026-01-01T00:00:00+00:00",
context=ctx,
)
d = r.to_dict()
r2 = RunReport.from_dict(d)
assert r2.run_id == "abc"
assert r2.context.project == "P"
assert r2.context.family == "article"