Add durable portable graph publication
This commit is contained in:
parent
96e3965855
commit
1134c2d375
19 changed files with 2542 additions and 13 deletions
344
tests/test_graph_publication.py
Normal file
344
tests/test_graph_publication.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue