from __future__ import annotations import contextlib import hashlib import io import json import shutil import tempfile import unittest from pathlib import Path from unittest import mock from docforge.changesets import ChangesetStore from docforge.cli import main from docforge.errors import DocForgeError from docforge.project import Project from docforge.rendering import RenderService ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" class DocForgeRenderingTests(unittest.TestCase): def copy_fixture(self, name: str, destination: Path) -> Path: root = destination / name shutil.copytree(FIXTURES / name, root) return root @staticmethod def project_content_hash(root: Path) -> str: digest = hashlib.sha256() paths = [ root / ".docforge/project.toml", root / "POLICY.md", *(root / "docs/content").glob("*"), *(root / "docs/templates").glob("*"), ] for path in sorted((path for path in paths if path.is_file()), key=lambda item: str(item)): digest.update(path.relative_to(root).as_posix().encode("utf-8")) digest.update(path.read_bytes()) return digest.hexdigest() @staticmethod def node_hash(project: Project, node_id: str) -> str: return next(node.content_hash for node in project.load().nodes if node.node_id == node_id) def test_declared_render_is_repeatable_and_status_detects_stale_output(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) project = Project.open(root) service = RenderService(project) missing = service.status() self.assertTrue(missing["configured"]) self.assertEqual("stale", missing["state"]) self.assertEqual("missing", missing["outputs"][0]["state"]) first = service.render("manual") projection_receipt = first["output"]["projection_receipt"] self.assertIsInstance(projection_receipt, dict) assert isinstance(projection_receipt, dict) self.assertEqual("manual", projection_receipt["kind"]) self.assertEqual( first["output"]["actual_output_hash"], projection_receipt["artifacts"][0]["sha256"], ) output = root / ".docforge/rendered/manual.html" first_bytes = output.read_bytes() second = service.render("manual") self.assertEqual( first["output"]["render_identity"], second["output"]["render_identity"] ) self.assertEqual( first["output"]["actual_output_hash"], second["output"]["actual_output_hash"] ) self.assertEqual(first_bytes, output.read_bytes()) self.assertEqual("current", service.status("manual")["state"]) workflow = root / "docs/content/workflow.md" workflow.write_text( workflow.read_text(encoding="utf-8") + "\nA new canonical sentence.\n", encoding="utf-8", ) stale = service.status("manual") self.assertEqual("stale", stale["state"]) self.assertEqual("stale", stale["outputs"][0]["state"]) self.assertEqual(first_bytes, output.read_bytes()) def test_warm_render_status_uses_only_publication_receipts(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) rendered = RenderService(Project.open(root)).render("manual") self.assertEqual("current", rendered["receipt"]["state"]) project = Project.open(root) service = RenderService(project) with ( mock.patch.object( project, "load", side_effect=AssertionError("receipt status must not load canonical source"), ), mock.patch.object( service, "_prepare", side_effect=AssertionError("receipt status must not render"), ), ): current = service.status("manual") self.assertEqual("current", current["state"]) self.assertEqual("receipt", current["verification"]) self.assertEqual("current", current["outputs"][0]["state"]) output = root / ".docforge/rendered/manual.html" output.write_bytes(output.read_bytes() + b"\n") changed_output = service.status("manual") self.assertEqual("stale", changed_output["state"]) self.assertEqual("output_changed", changed_output["outputs"][0]["reason"]) RenderService(Project.open(root)).render("manual") template = root / "docs/templates/manual.html" template.write_text( template.read_text(encoding="utf-8") + "\n", encoding="utf-8", ) changed_template = service.status("manual") self.assertEqual("template_changed", changed_template["outputs"][0]["reason"]) def test_render_receipt_failures_are_degraded_after_output_publication(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) service = RenderService(Project.open(root)) with mock.patch.object( service, "_publish_receipt", side_effect=DocForgeError( "render_receipt_failure", "Synthetic receipt failure", ), ): result = service.render("manual") self.assertEqual("degraded", result["state"]) self.assertEqual("published", result["publication"]) self.assertEqual("failed", result["receipt"]["state"]) self.assertTrue((root / ".docforge/rendered/manual.html").is_file()) def test_render_receipt_refuses_post_render_input_and_output_changes(self) -> None: for changed in ("template", "output", "source"): with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) service = RenderService(Project.open(root)) publish = service._publish_receipt def mutate_then_publish( snapshot, view, prepared, *, changed_kind=changed, project_root=root, publish_receipt=publish, ): if changed_kind == "template": target = project_root / "docs/templates/manual.html" elif changed_kind == "output": target = project_root / ".docforge/rendered/manual.html" else: target = project_root / "docs/content/workflow.md" target.write_bytes(target.read_bytes() + b"\nChanged before receipt.\n") return publish_receipt(snapshot, view, prepared) with mock.patch.object( service, "_publish_receipt", side_effect=mutate_then_publish, ): result = service.render("manual") self.assertEqual("degraded", result["state"]) self.assertEqual("published", result["publication"]) self.assertNotEqual("current", service.status("manual")["state"]) self.assertEqual("stale", service.deep_status("manual")["state"]) def test_missing_and_corrupt_render_receipts_are_conservative(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) service = RenderService(Project.open(root)) service.render("manual") receipt = root / ".docforge/cache/render-receipts/manual.json" receipt.unlink() missing = service.status("manual") self.assertEqual("unverified", missing["outputs"][0]["state"]) self.assertEqual("receipt_missing", missing["outputs"][0]["reason"]) receipt.write_text("{not-json", encoding="utf-8") corrupt = service.status("manual") self.assertEqual("unverified", corrupt["outputs"][0]["state"]) self.assertEqual("receipt_corrupt", corrupt["outputs"][0]["reason"]) def test_render_receipt_schema_and_renderer_version_fail_closed(self) -> None: for mutation in ( "missing_hash", "renderer_version", "file_identity", "projection_artifact", ): with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) service = RenderService(Project.open(root)) service.render("manual") receipt_path = root / ".docforge/cache/render-receipts/manual.json" receipt = json.loads(receipt_path.read_text(encoding="utf-8")) if mutation == "missing_hash": receipt.pop("output_hash") elif mutation == "renderer_version": receipt["renderer_version"] = "obsolete" elif mutation == "projection_artifact": receipt["projection_receipt"]["artifacts"][0]["sha256"] = "0" * 64 else: receipt["output_file"].pop("ctime_ns") receipt_path.write_text( json.dumps(receipt, sort_keys=True, indent=2) + "\n", encoding="utf-8", ) status = service.status("manual") self.assertEqual("unverified", status["outputs"][0]["state"]) self.assertEqual( "foreign_or_incompatible_receipt", status["outputs"][0]["reason"], ) def test_render_status_detects_change_between_bounded_captures(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) service = RenderService(Project.open(root)) service.render("manual") receipt_status = service._receipt_status calls = 0 def mutate_between_captures(descriptor, view, current_state): nonlocal calls calls += 1 if calls == 2: output = root / ".docforge/rendered/manual.html" output.write_bytes(output.read_bytes() + b"\n") return receipt_status(descriptor, view, current_state) with mock.patch.object( service, "_receipt_status", side_effect=mutate_between_captures, ): result = service.status("manual") self.assertEqual("stale", result["state"]) self.assertNotEqual("current", result["outputs"][0]["state"]) def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) project = Project.open(root) changesets = ChangesetStore(project, "alpha-editor") service = RenderService(project, changesets) canonical_before = self.project_content_hash(root) canonical_render = service.render("manual") committed_output = root / ".docforge/rendered/manual.html" committed_before = committed_output.read_bytes() created = changesets.create("user-preview") proposed = changesets.propose_update( changeset_id="user-preview", expected_changeset_hash=created["changeset_hash"], node_id="guide.workflow", expected_content_hash=self.node_hash(project, "guide.workflow"), metadata={"summary": "A summary visible only in the preview."}, content="\n\n**Rendered safely.**", relationship_changes=[], rationale="Show the proposed content through the declared view.", ) first = service.preview("user-preview", "manual") preview_path = root / ".docforge/previews/user-preview/manual.html" preview_bytes = preview_path.read_bytes() second = service.preview("user-preview", "manual") self.assertEqual(proposed["changeset_hash"], first["changeset_hash"]) self.assertEqual(first["preview_identity"], second["preview_identity"]) self.assertEqual(preview_bytes, preview_path.read_bytes()) self.assertNotEqual( canonical_render["output"]["render_identity"], first["preview_identity"] ) html = preview_bytes.decode("utf-8") self.assertIn("<script>", html) self.assertNotIn("