344 lines
14 KiB
Python
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()
|