from __future__ import annotations import json import shutil import tempfile import tomllib import unittest from pathlib import Path from unittest import mock from jsonschema import Draft202012Validator from docforge.errors import DocForgeError from docforge.graph_rendering import GraphRenderService from docforge.models import ProjectState from docforge.project import Project from docforge.projection_contract import projection_hash ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" PROJECT_SCHEMA = json.loads((ROOT / "schemas/project.schema.json").read_text(encoding="utf-8")) GRAPH_CONFIG = """ [graph_render] output_root = ".docforge/portable-graph" [[graph_render.views]] id = "architecture" renderer = "portable_graph_html" output = "architecture.html" title = "Alpha architecture" root = "guide.workflow" initial_mode = "nodes" depth = 2 max_nodes = 20 max_edges = 40 max_work = 1000 families = ["guide", "proof"] relations = ["depends_on", "proves"] authorities = [] statuses = [] tags = [] include_logic = false """ class GraphPublicationTests(unittest.TestCase): def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) / "alpha" shutil.copytree(FIXTURES / "alpha", self.root) descriptor = self.root / ".docforge/project.toml" descriptor.write_text( descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG, encoding="utf-8", ) self.project = Project.open(self.root) self.service = GraphRenderService(self.project) def test_descriptor_schema_and_runtime_accept_the_separate_graph_view(self) -> None: document = tomllib.loads((self.root / ".docforge/project.toml").read_text(encoding="utf-8")) Draft202012Validator(PROJECT_SCHEMA).validate(document) config = self.project.descriptor.graph_render assert config is not None self.assertEqual(self.root / ".docforge/portable-graph", config.output_root) self.assertEqual("architecture", config.views[0].view_id) self.assertEqual("guide.workflow", config.views[0].root_node_id) self.assertIsNone(config.views[0].query) def test_render_publication_is_deterministic_durable_and_unchanged_on_reuse(self) -> None: missing = self.service.status("architecture") self.assertEqual("stale", missing["state"]) self.assertEqual("missing", missing["outputs"][0]["state"]) first = self.service.render("architecture") output = self.root / ".docforge/portable-graph/architecture.html" manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json" artifact_root = self.root / ".docforge/cache/projection-artifacts" self.assertEqual("current", first["state"]) self.assertEqual("published", first["publication"]) self.assertTrue(output.is_file()) self.assertTrue(manifest.is_file()) self.assertEqual(1, len(tuple(artifact_root.glob("*.html")))) before = output.stat() before_bytes = output.read_bytes() current = self.service.status("architecture") self.assertEqual("current", current["state"]) self.assertEqual("manifest", current["outputs"][0]["verification"]) second = self.service.render("architecture") after = output.stat() self.assertEqual("unchanged", second["publication"]) self.assertEqual(before_bytes, output.read_bytes()) self.assertEqual((before.st_dev, before.st_ino), (after.st_dev, after.st_ino)) def test_status_is_manifest_only_and_detects_source_output_and_manifest_changes(self) -> None: self.service.render("architecture") output = self.root / ".docforge/portable-graph/architecture.html" manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json" with mock.patch.object( self.project, "load", side_effect=AssertionError("status must not load or plan"), ): self.assertEqual("current", self.service.status("architecture")["state"]) output.write_bytes(output.read_bytes() + b"\n") changed_output = self.service.status("architecture") self.assertEqual("stale", changed_output["state"]) self.assertEqual("output_changed", changed_output["outputs"][0]["reason"]) self.service.render("architecture") source = self.root / "docs/content/workflow.md" source.write_text( source.read_text(encoding="utf-8") + "\nChanged after publication.\n", encoding="utf-8", ) changed_source = self.service.status("architecture") self.assertEqual("stale", changed_source["state"]) self.assertEqual("stale", changed_source["outputs"][0]["state"]) self.assertEqual( "source_generation_changed", changed_source["outputs"][0]["reason"], ) self.service.render("architecture") manifest.write_text("{bad-json", encoding="utf-8") corrupt = self.service.status("architecture") self.assertEqual("missing", corrupt["outputs"][0]["state"]) def test_status_validates_nested_manifest_and_artifact_store_evidence(self) -> None: self.service.render("architecture") manifest_path = ( self.root / ".docforge/cache/projection-publications/graph/architecture.json" ) original = json.loads(manifest_path.read_text(encoding="utf-8")) mutations = { "bad_project": lambda value: value.__setitem__("project", "bad"), "forged_artifact": lambda value: value.__setitem__( "artifact", { "artifact_id": "portable-graph.html", "media_type": "text/html; charset=utf-8", "sha256": "0" * 64, "bytes": 1, }, ), "bad_store": lambda value: value["store"].__setitem__("size", -1), } for name, mutate in mutations.items(): with self.subTest(name=name): value = json.loads(json.dumps(original)) mutate(value) value.pop("publication_id") value["publication_id"] = projection_hash(value) manifest_path.write_text( json.dumps(value, sort_keys=True, indent=2) + "\n", encoding="utf-8", ) status = self.service.status("architecture") self.assertEqual("unverified", status["outputs"][0]["state"]) self.assertEqual("manifest_invalid", status["outputs"][0]["reason"]) manifest_path.write_text( json.dumps(original, sort_keys=True, indent=2) + "\n", encoding="utf-8", ) artifact = next((self.root / ".docforge/cache/projection-artifacts").glob("*.html")) artifact.unlink() missing = self.service.status("architecture") self.assertEqual("stale", missing["outputs"][0]["state"]) self.assertEqual("artifact_store_missing", missing["outputs"][0]["reason"]) repaired = self.service.render("architecture") self.assertEqual("current", repaired["state"]) self.assertTrue(artifact.is_file()) def test_status_detects_source_and_publication_races(self) -> None: self.service.render("architecture") current = self.project.incremental_state() assert current is not None changed = ProjectState(source_hash="0" * 64, revision="changed") with mock.patch.object( self.project, "incremental_state", side_effect=(current, changed), ): raced_source = self.service.status("architecture") self.assertEqual("stale", raced_source["state"]) self.assertEqual( "source_changed_during_status", raced_source["outputs"][0]["reason"], ) baseline = self.service._manifest_status( self.project.descriptor.graph_render.views[0], # type: ignore[union-attr] current, ) replaced = dict(baseline) replaced["publication_id"] = "f" * 64 with mock.patch.object( self.service, "_manifest_status", side_effect=(baseline, replaced), ): raced_publication = self.service.status("architecture") self.assertEqual("stale", raced_publication["state"]) self.assertEqual( "publication_changed_during_status", raced_publication["outputs"][0]["reason"], ) def test_manifest_failure_after_output_is_degraded_success(self) -> None: with mock.patch.object( self.service, "_publish_manifest", side_effect=DocForgeError( "publication_failure", "Synthetic manifest failure", ), ): result = self.service.render("architecture") self.assertEqual("degraded", result["state"]) self.assertEqual("published", result["publication"]) self.assertEqual("manifest", result["committed_stage"]) self.assertTrue((self.root / ".docforge/portable-graph/architecture.html").is_file()) self.assertEqual("failed", result["manifest"]["state"]) def test_post_commit_stage_failures_are_degraded_and_precommit_failures_raise(self) -> None: committed = DocForgeError( "publication_failure", "Synthetic committed failure", mutation_committed=True, ) cases = ( ("_publish_artifact", "artifact_store", "partial", False), ("_publish_output", "output", "published", None), ) for method, stage, publication, output_exists in cases: with self.subTest(method=method): root = Path(self.temporary.name) / method shutil.copytree(FIXTURES / "alpha", root) descriptor = root / ".docforge/project.toml" descriptor.write_text( descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG, encoding="utf-8", ) service = GraphRenderService(Project.open(root)) with mock.patch.object(service, method, side_effect=committed): result = service.render("architecture") self.assertEqual("degraded", result["state"]) self.assertEqual(stage, result["committed_stage"]) self.assertEqual(publication, result["publication"]) if output_exists is not None: self.assertEqual( output_exists, (root / ".docforge/portable-graph/architecture.html").exists(), ) uncommitted = DocForgeError( "publication_failure", "Synthetic precommit failure", mutation_committed=False, ) with ( mock.patch.object( self.service, "_publish_output", side_effect=uncommitted, ), self.assertRaises(DocForgeError), ): self.service.render("architecture") def test_configuration_rejects_unsafe_ambiguous_and_overlapping_views(self) -> None: cases = { "both_scope": GRAPH_CONFIG.replace( 'root = "guide.workflow"', 'root = "guide.workflow"\nquery = "workflow"', ), "output_overlap": GRAPH_CONFIG.replace( 'output_root = ".docforge/portable-graph"', 'output_root = "docs/content"', ), "active_renderer": GRAPH_CONFIG.replace( 'renderer = "portable_graph_html"', 'renderer = "shell"', ), "oversized": GRAPH_CONFIG.replace("max_nodes = 20", "max_nodes = 100000"), "logic_mode": GRAPH_CONFIG.replace('initial_mode = "nodes"', 'initial_mode = "logic"'), "logic_projection": GRAPH_CONFIG.replace( "include_logic = false", "include_logic = true", ), "long_title": GRAPH_CONFIG.replace( 'title = "Alpha architecture"', f'title = "{"x" * 1025}"', ), "long_query": GRAPH_CONFIG.replace( 'root = "guide.workflow"', f'query = "{"x" * 10001}"', ), "too_many_filters": GRAPH_CONFIG.replace( 'families = ["guide", "proof"]', "families = [" + ", ".join(f'"family-{index}"' for index in range(65)) + "]", ), } for name, graph_config in cases.items(): with self.subTest(name=name): root = Path(self.temporary.name) / name shutil.copytree(FIXTURES / "alpha", root) descriptor = root / ".docforge/project.toml" descriptor.write_text( descriptor.read_text(encoding="utf-8") + graph_config, encoding="utf-8", ) parsed = tomllib.loads(descriptor.read_text(encoding="utf-8")) if name != "output_overlap": self.assertFalse(Draft202012Validator(PROJECT_SCHEMA).is_valid(parsed)) with self.assertRaises(DocForgeError): Project.open(root) relocated = Path(self.temporary.name) / "descriptor-overlap" shutil.copytree(FIXTURES / "alpha", relocated) descriptor = relocated / ".docforge/project.toml" base = descriptor.read_text(encoding="utf-8").replace( 'cache_root = ".docforge/cache"\nindex = ".docforge/cache/index.sqlite3"', 'cache_root = "var/cache"\nindex = "var/cache/index.sqlite3"', ) descriptor.write_text( base + GRAPH_CONFIG.replace( 'output_root = ".docforge/portable-graph"', 'output_root = ".docforge"', ), encoding="utf-8", ) with self.assertRaises(DocForgeError): Project.open(relocated) if __name__ == "__main__": unittest.main()