generated from coulomb/repo-seed
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>
381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""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}"
|