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")
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_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("