generated from coulomb/repo-seed
feat: WP-0003 complete — LEVEL3 advanced features + error framework
Implements full LEVEL3 feature set: cross-references (xref.py), numbered figures (figures.py), auto-diagrams (diagrams.py), bibliography/citations (bibliography.py), LEVEL3 capability detection (level3.py), and structured error/warning records (errors.py). Builder, importer, and differ updated for LEVEL3 round-trip support. REST and MCP interfaces updated with structured warning records. 259 tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
380
tests/test_error_framework.py
Normal file
380
tests/test_error_framework.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""Tests for structured error & warning framework (FR-1201–1210)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WarningRecord / FailureRecord / OutputState types (FR-1208–1210)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWarningRecord:
|
||||
def test_to_dict(self) -> None:
|
||||
from markidocx.errors import Severity, WarningRecord
|
||||
|
||||
w = WarningRecord(severity=Severity.WARNING, reason="unsupported-construct", construct="html_block")
|
||||
d = w.to_dict()
|
||||
assert d["severity"] == "warning"
|
||||
assert d["reason"] == "unsupported-construct"
|
||||
assert d["construct"] == "html_block"
|
||||
|
||||
def test_str_with_construct(self) -> None:
|
||||
from markidocx.errors import WarningRecord
|
||||
|
||||
w = WarningRecord(severity="warning", reason="test-reason", construct="my-token")
|
||||
assert "warning" in str(w)
|
||||
assert "test-reason" in str(w)
|
||||
assert "my-token" in str(w)
|
||||
|
||||
def test_str_without_construct(self) -> None:
|
||||
from markidocx.errors import WarningRecord
|
||||
|
||||
w = WarningRecord(severity="info", reason="test-reason")
|
||||
s = str(w)
|
||||
assert "info" in s
|
||||
assert "test-reason" in s
|
||||
|
||||
|
||||
class TestFailureRecord:
|
||||
def test_to_dict(self) -> None:
|
||||
from markidocx.errors import FailureRecord, Severity
|
||||
|
||||
f = FailureRecord(severity=Severity.ERROR, reason="docx-not-found", construct="some.docx")
|
||||
d = f.to_dict()
|
||||
assert d["severity"] == "error"
|
||||
assert d["reason"] == "docx-not-found"
|
||||
|
||||
|
||||
class TestOutputState:
|
||||
def test_all_states_defined(self) -> None:
|
||||
from markidocx.errors import OutputState
|
||||
|
||||
assert OutputState.FINAL == "final"
|
||||
assert OutputState.PARTIAL == "partial"
|
||||
assert OutputState.FALLBACK == "fallback"
|
||||
assert OutputState.DEGRADED == "degraded"
|
||||
assert OutputState.UNRESOLVED == "unresolved"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builder emits WarningRecord for unsupported constructs (FR-1203, FR-1205)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuilderWarningRecords:
|
||||
def test_unsupported_html_emits_warning_record(self, tmp_path: Path) -> None:
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.errors import Severity
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
(tmp_path / "doc.md").write_text(
|
||||
"# Hello\n\n<div>raw html</div>\n\nNormal paragraph.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
result = build_document(m)
|
||||
assert result.success
|
||||
assert len(result.warning_records) > 0
|
||||
html_warnings = [w for w in result.warning_records if "html" in w.construct]
|
||||
assert html_warnings, "Expected warning for html construct"
|
||||
assert all(w.severity == Severity.WARNING for w in html_warnings)
|
||||
|
||||
def test_warning_records_have_reason(self, tmp_path: Path) -> None:
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
(tmp_path / "doc.md").write_text(
|
||||
"# Hello\n\n<div>raw html</div>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
result = build_document(m)
|
||||
for w in result.warning_records:
|
||||
assert w.reason, "WarningRecord must have a non-empty reason"
|
||||
|
||||
def test_warnings_property_returns_strings(self, tmp_path: Path) -> None:
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
(tmp_path / "doc.md").write_text("# Hello\n\n<div>html</div>", encoding="utf-8")
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
result = build_document(m)
|
||||
assert all(isinstance(w, str) for w in result.warnings)
|
||||
|
||||
def test_output_state_on_clean_build(self, tmp_path: Path) -> None:
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.errors import OutputState
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
(tmp_path / "doc.md").write_text("# Hello\n\nContent.", encoding="utf-8")
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: clean
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
result = build_document(m)
|
||||
assert result.output_state == OutputState.FINAL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Importer emits WarningRecord for errors and fallback paths (FR-1206, FR-1207)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImporterWarningRecords:
|
||||
def test_not_found_emits_error_warning_record(self, tmp_path: Path) -> None:
|
||||
from markidocx.errors import OutputState, Severity
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
(tmp_path / "doc.md").write_text("# Hello", encoding="utf-8")
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
result = import_document(m, tmp_path / "missing.docx")
|
||||
assert not result.success
|
||||
assert result.output_state == OutputState.UNRESOLVED
|
||||
assert len(result.warning_records) > 0
|
||||
assert result.warning_records[0].severity == Severity.ERROR
|
||||
assert result.warning_records[0].reason == "docx-not-found"
|
||||
|
||||
def test_warnings_property_returns_strings(self, tmp_path: Path) -> None:
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
(tmp_path / "doc.md").write_text("# Hello", encoding="utf-8")
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
result = import_document(m, tmp_path / "missing.docx")
|
||||
assert all(isinstance(w, str) for w in result.warnings)
|
||||
|
||||
def test_fallback_emits_fallback_warning(self, tmp_path: Path) -> None:
|
||||
"""Multi-source import that can't redistribute produces fallback WarningRecord."""
|
||||
from markidocx.builder import build_document
|
||||
from markidocx.errors import OutputState
|
||||
from markidocx.importer import import_document
|
||||
from markidocx.manifest import load_manifest
|
||||
|
||||
# Create two source files — the DOCX will have a single H1 so redistribution fails
|
||||
(tmp_path / "a.md").write_text("# Alpha\n\nContent.", encoding="utf-8")
|
||||
(tmp_path / "b.md").write_text("# Beta\n\nContent.", encoding="utf-8")
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: multi
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: a.md
|
||||
- path: b.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_manifest(tmp_path / "manifest.yaml")
|
||||
|
||||
# Build first to get a DOCX
|
||||
build_result = build_document(m)
|
||||
assert build_result.success
|
||||
|
||||
# Now import with a manifest that has 3 sources (mismatch)
|
||||
(tmp_path / "c.md").write_text("# Gamma\n\nContent.", encoding="utf-8")
|
||||
(tmp_path / "manifest3.yaml").write_text(
|
||||
textwrap.dedent("""\
|
||||
project:
|
||||
name: multi
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: a.md
|
||||
- path: b.md
|
||||
- path: c.md
|
||||
output:
|
||||
dir: ./dist
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
m3 = load_manifest(tmp_path / "manifest3.yaml")
|
||||
result = import_document(m3, build_result.output_path)
|
||||
assert result.success
|
||||
assert result.mapping_status == "merged"
|
||||
assert result.output_state == OutputState.FALLBACK
|
||||
fallback_warnings = [w for w in result.warning_records if w.reason == "fallback"]
|
||||
assert fallback_warnings, "Expected fallback WarningRecord"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Differ output_state (FR-1204)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDifferOutputState:
|
||||
def test_final_state_on_clean_diff(self) -> None:
|
||||
from markidocx.differ import compare
|
||||
from markidocx.errors import OutputState
|
||||
|
||||
text = "# Hello\n\nSome paragraph.\n\n- item one\n- item two"
|
||||
report = compare(text, text)
|
||||
assert not report.has_drift
|
||||
assert report.output_state == OutputState.FINAL
|
||||
|
||||
def test_degraded_state_on_degraded_diff(self) -> None:
|
||||
from markidocx.differ import compare
|
||||
from markidocx.errors import OutputState
|
||||
|
||||
original = "# Hello\n\n- item one\n- item two\n- item three"
|
||||
reimported = "# Hello\n\n- item one"
|
||||
report = compare(original, reimported)
|
||||
assert report.has_drift
|
||||
assert report.output_state in (OutputState.DEGRADED, OutputState.PARTIAL)
|
||||
|
||||
def test_partial_state_on_broken_diff(self) -> None:
|
||||
from markidocx.differ import compare
|
||||
from markidocx.errors import OutputState
|
||||
|
||||
original = "# Section A\n\n## Sub\n\nParagraph."
|
||||
reimported = ""
|
||||
report = compare(original, reimported)
|
||||
assert report.has_drift
|
||||
assert report.output_state == OutputState.PARTIAL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST response envelope warnings are WarningRecord dicts (FR-1208)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRestWarningRecords:
|
||||
def test_build_warnings_are_dicts(self, tmp_path: Path) -> None:
|
||||
"""When build produces warnings, REST response warnings are dicts, not bare strings."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from markidocx.rest import create_app
|
||||
|
||||
manifest_yaml = textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
""")
|
||||
# HTML in source will produce warnings
|
||||
sources = [{"name": "doc.md", "content": "# Hello\n\n<div>html</div>"}]
|
||||
client = TestClient(create_app())
|
||||
resp = client.post("/build", json={"manifest_yaml": manifest_yaml, "sources": sources})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
warnings = body.get("warnings", [])
|
||||
# Each warning should be a dict with severity/reason/construct keys
|
||||
for w in warnings:
|
||||
assert isinstance(w, dict), f"Expected dict warning, got {type(w)}: {w}"
|
||||
assert "severity" in w
|
||||
assert "reason" in w
|
||||
|
||||
def test_import_warnings_are_dicts_on_failure(self) -> None:
|
||||
"""Import failure warns with WarningRecord dict, not bare string."""
|
||||
import base64
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from markidocx.rest import create_app
|
||||
|
||||
manifest_yaml = textwrap.dedent("""\
|
||||
project:
|
||||
name: test
|
||||
feature_level: level1
|
||||
family: article
|
||||
sources:
|
||||
- path: doc.md
|
||||
output:
|
||||
dir: ./dist
|
||||
""")
|
||||
# Send an invalid (empty) DOCX
|
||||
empty_docx = base64.b64encode(b"not-a-docx").decode()
|
||||
client = TestClient(create_app())
|
||||
resp = client.post(
|
||||
"/import",
|
||||
json={"manifest_yaml": manifest_yaml, "docx_base64": empty_docx},
|
||||
)
|
||||
body = resp.json()
|
||||
warnings = body.get("warnings", [])
|
||||
for w in warnings:
|
||||
assert isinstance(w, dict), f"Expected dict warning, got {type(w)}: {w}"
|
||||
Reference in New Issue
Block a user