"""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\n
raw 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\n
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) 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
html
"}] 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}"