"""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
raw html
\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\nraw html
",
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\nhtml
", 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\nhtml
"}]
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}"