1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/tests/test_graph_publication.py

344 lines
14 KiB
Python

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