Add durable portable graph publication
This commit is contained in:
parent
96e3965855
commit
1134c2d375
19 changed files with 2542 additions and 13 deletions
|
|
@ -17,6 +17,25 @@ from docforge.viewer_manager import ViewerManager
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
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
|
||||
include_logic = false
|
||||
"""
|
||||
|
||||
|
||||
class DocForgeCliTests(unittest.TestCase):
|
||||
def copy_fixture(self, destination: Path) -> Path:
|
||||
|
|
@ -134,6 +153,30 @@ class DocForgeCliTests(unittest.TestCase):
|
|||
else:
|
||||
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous
|
||||
|
||||
def test_portable_graph_plan_render_and_status_are_self_service(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
descriptor = root / ".docforge/project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
|
||||
encoding="utf-8",
|
||||
)
|
||||
parser = _parser()
|
||||
planned = _run(
|
||||
parser.parse_args(["--project-root", str(root), "graph-plan", "architecture"])
|
||||
)
|
||||
self.assertEqual("architecture", planned["view_id"])
|
||||
rendered = _run(
|
||||
parser.parse_args(["--project-root", str(root), "graph-render", "architecture"])
|
||||
)
|
||||
self.assertEqual("current", rendered["state"])
|
||||
status = _run(
|
||||
parser.parse_args(
|
||||
["--project-root", str(root), "graph-render-status", "architecture"]
|
||||
)
|
||||
)
|
||||
self.assertEqual("current", status["state"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
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()
|
||||
284
tests/test_graph_rendering.py
Normal file
284
tests/test_graph_rendering.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from unittest import mock
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.graph_projection import (
|
||||
GraphViewRequestV1,
|
||||
build_graph_projection_package,
|
||||
build_graph_view_plan,
|
||||
)
|
||||
from docforge.project import Project
|
||||
from docforge.projection_contract import ProjectionPackageV1
|
||||
from docforge_renderers.graph import PortableGraphHtmlRenderer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
class PortableGraphRenderingTests(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)
|
||||
self.project = Project.open(self.root)
|
||||
self.snapshot = self.project.load()
|
||||
self.request = GraphViewRequestV1(
|
||||
view_id="architecture",
|
||||
title="Alpha architecture",
|
||||
root_node_id="guide.workflow",
|
||||
depth=2,
|
||||
max_nodes=20,
|
||||
max_edges=40,
|
||||
max_work=1_000,
|
||||
)
|
||||
self.plan = build_graph_view_plan(self.snapshot, self.request, False)
|
||||
self.package = build_graph_projection_package(
|
||||
self.plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
|
||||
def test_portable_artifact_is_deterministic_self_contained_and_generation_bound(self) -> None:
|
||||
renderer = PortableGraphHtmlRenderer()
|
||||
first = renderer.render(self.package)
|
||||
second = renderer.render(self.package)
|
||||
self.assertEqual(first.artifacts, second.artifacts)
|
||||
self.assertEqual(1, len(first.artifacts))
|
||||
artifact = first.artifacts[0]
|
||||
self.assertEqual("portable-graph.html", artifact.artifact_id)
|
||||
self.assertEqual("text/html; charset=utf-8", artifact.media_type)
|
||||
receipt = first.receipt.as_dict()
|
||||
evidence = cast(list[dict[str, object]], receipt["artifacts"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(artifact.content).hexdigest(),
|
||||
evidence[0]["sha256"],
|
||||
)
|
||||
rendered = artifact.content.decode("utf-8")
|
||||
self.assertIn("Content-Security-Policy", rendered)
|
||||
self.assertIn("default-src 'none'", rendered)
|
||||
self.assertIn('type="application/json"', rendered)
|
||||
self.assertNotIn(str(self.root), rendered)
|
||||
self.assertNotIn("docs/content/", rendered)
|
||||
self.assertNotIn("Editors change canonical nodes", rendered)
|
||||
self.assertNotIn("fetch(", rendered)
|
||||
self.assertNotIn("XMLHttpRequest", rendered)
|
||||
self.assertNotIn("WebSocket", rendered)
|
||||
self.assertIn(self.snapshot.source_hash, rendered)
|
||||
|
||||
def test_embedded_plan_is_exact_and_script_breakout_is_inert(self) -> None:
|
||||
plan = copy.deepcopy(self.plan.as_dict())
|
||||
view = plan["view"]
|
||||
assert isinstance(view, dict)
|
||||
view["title"] = '</script><script id="injected">alert(1)</script>'
|
||||
plan.pop("plan_id")
|
||||
from docforge.projection_contract import GraphViewPlanV1
|
||||
|
||||
malicious = GraphViewPlanV1.create(plan)
|
||||
package = build_graph_projection_package(
|
||||
malicious,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
rendered = PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
|
||||
self.assertNotIn('<script id="injected">', rendered)
|
||||
self.assertIn("</script>", rendered)
|
||||
prefix = '<script id="docforge-graph-plan" type="application/json">'
|
||||
embedded = rendered.split(prefix, 1)[1].split("</script>", 1)[0]
|
||||
self.assertEqual(malicious.as_dict(), json.loads(embedded))
|
||||
|
||||
def test_renderer_has_no_project_database_or_filesystem_write_capability(self) -> None:
|
||||
forbidden = AssertionError("portable graph renderer crossed its capability boundary")
|
||||
with (
|
||||
mock.patch.object(Project, "open", side_effect=forbidden),
|
||||
mock.patch.object(Project, "load", side_effect=forbidden),
|
||||
mock.patch.object(sqlite3, "connect", side_effect=forbidden),
|
||||
mock.patch.object(Path, "write_bytes", side_effect=forbidden),
|
||||
mock.patch.object(Path, "write_text", side_effect=forbidden),
|
||||
mock.patch.object(Path, "mkdir", side_effect=forbidden),
|
||||
mock.patch.object(os, "replace", side_effect=forbidden),
|
||||
mock.patch.object(os, "rename", side_effect=forbidden),
|
||||
mock.patch.object(os, "unlink", side_effect=forbidden),
|
||||
):
|
||||
result = PortableGraphHtmlRenderer().render(self.package)
|
||||
self.assertEqual(1, len(result.artifacts))
|
||||
|
||||
def test_renderer_rejects_wrong_kind_identity_assets_and_size(self) -> None:
|
||||
wrong = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": "other",
|
||||
},
|
||||
components=[],
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["portable-graph.html"],
|
||||
"max_total_bytes": 1_000_000,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as unsupported:
|
||||
PortableGraphHtmlRenderer().render(wrong)
|
||||
self.assertEqual("unsupported_renderer", unsupported.exception.code)
|
||||
|
||||
with_asset = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
|
||||
},
|
||||
components=[],
|
||||
assets=[
|
||||
{
|
||||
"asset_id": "project-script",
|
||||
"media_type": "text/javascript",
|
||||
"sha256": "0" * 64,
|
||||
"text": "alert(1)",
|
||||
}
|
||||
],
|
||||
output_policy={"artifact_ids": ["portable-graph.html"], "max_total_bytes": 1_000_000},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as assets:
|
||||
PortableGraphHtmlRenderer().render(with_asset)
|
||||
self.assertEqual("invalid_projection", assets.exception.code)
|
||||
|
||||
wrong_components = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
|
||||
},
|
||||
components=[],
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["portable-graph.html"],
|
||||
"max_total_bytes": 1_000_000,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as components:
|
||||
PortableGraphHtmlRenderer().render(wrong_components)
|
||||
self.assertEqual("invalid_projection", components.exception.code)
|
||||
|
||||
wrong_artifact = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
|
||||
},
|
||||
components=self.package.document["components"], # type: ignore[arg-type]
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["unexpected.html"],
|
||||
"max_total_bytes": 1_000_000,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as artifact:
|
||||
PortableGraphHtmlRenderer().render(wrong_artifact)
|
||||
self.assertEqual("invalid_projection", artifact.exception.code)
|
||||
|
||||
tiny = build_graph_projection_package(
|
||||
self.plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1,
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as too_large:
|
||||
PortableGraphHtmlRenderer().render(tiny)
|
||||
self.assertEqual("render_too_large", too_large.exception.code)
|
||||
|
||||
def test_initial_mode_is_honored_and_logic_is_rejected(self) -> None:
|
||||
for mode in ("nodes", "flow", "web"):
|
||||
with self.subTest(mode=mode):
|
||||
plan = build_graph_view_plan(
|
||||
self.snapshot,
|
||||
GraphViewRequestV1(
|
||||
view_id=f"{mode}-view",
|
||||
title=f"{mode.title()} view",
|
||||
root_node_id="guide.workflow",
|
||||
initial_mode=mode, # type: ignore[arg-type]
|
||||
depth=2,
|
||||
max_nodes=20,
|
||||
max_edges=40,
|
||||
max_work=1_000,
|
||||
),
|
||||
False,
|
||||
)
|
||||
package = build_graph_projection_package(
|
||||
plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
rendered = (
|
||||
PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
|
||||
)
|
||||
self.assertIn(f'data-mode="{mode}"', rendered)
|
||||
self.assertIn(f'<option value="{mode}" selected>', rendered)
|
||||
|
||||
logic_plan = build_graph_view_plan(
|
||||
self.snapshot,
|
||||
GraphViewRequestV1(
|
||||
view_id="logic-view",
|
||||
title="Logic view",
|
||||
root_node_id="guide.workflow",
|
||||
initial_mode="logic",
|
||||
include_logic=True,
|
||||
),
|
||||
True,
|
||||
)
|
||||
logic_package = build_graph_projection_package(
|
||||
logic_plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as logic:
|
||||
PortableGraphHtmlRenderer().render(logic_package)
|
||||
self.assertEqual("unsupported_renderer", logic.exception.code)
|
||||
|
||||
def test_artifact_exposes_accessible_static_and_interactive_structure(self) -> None:
|
||||
rendered = (
|
||||
PortableGraphHtmlRenderer().render(self.package).artifacts[0].content.decode("utf-8")
|
||||
)
|
||||
static = rendered.split('<script id="docforge-graph-plan"', 1)[0]
|
||||
self.assertIn("Editing workflow (guide.workflow)", static)
|
||||
self.assertIn("<td>guide.workflow</td>", static)
|
||||
self.assertIn('data-panel="nodes"', static)
|
||||
self.assertIn('data-panel="relationships"', static)
|
||||
for expected in (
|
||||
'href="#main"',
|
||||
'<main id="main" tabindex="-1" data-mode="nodes">',
|
||||
'role="group" aria-label="Graph controls"',
|
||||
'role="status" aria-live="polite"',
|
||||
'<th scope="col">Source</th>',
|
||||
'<th scope="col">Relation</th>',
|
||||
'<th scope="col">Target</th>',
|
||||
'<dialog id="node-dialog"',
|
||||
'aria-labelledby="node-dialog-title"',
|
||||
'dialog.addEventListener("close", () => opener?.focus())',
|
||||
"@media (prefers-reduced-motion: reduce)",
|
||||
"@media (forced-colors: active)",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -8,12 +8,14 @@ from pathlib import Path
|
|||
from jsonschema import Draft202012Validator
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.graph_projection import build_graph_projection_package
|
||||
from docforge.projection_contract import (
|
||||
GraphViewPlanV1,
|
||||
ManualRenderPlanV1,
|
||||
ProjectionPackageV1,
|
||||
ProjectionReceiptV1,
|
||||
)
|
||||
from docforge_renderers.graph import PortableGraphHtmlRenderer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCHEMAS = ROOT / "schemas"
|
||||
|
|
@ -220,6 +222,14 @@ class ProjectionSchemaTests(unittest.TestCase):
|
|||
|
||||
graph_package = self.package(self.graph_plan()).as_dict()
|
||||
self.validator("projection-package.schema.json").validate(graph_package)
|
||||
typed_graph_package = build_graph_projection_package(
|
||||
self.graph_plan(),
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
graph_receipt = PortableGraphHtmlRenderer().render(typed_graph_package).receipt.as_dict()
|
||||
self.validator("projection-receipt.schema.json").validate(graph_receipt)
|
||||
|
||||
def test_unknown_fields_are_rejected_at_contract_boundaries(self) -> None:
|
||||
cases = (
|
||||
|
|
|
|||
|
|
@ -66,8 +66,10 @@ PUBLIC_IMPORTS = {
|
|||
"docforge.index": ("ProjectIndex",),
|
||||
"docforge.graph_projection": (
|
||||
"GraphViewRequestV1",
|
||||
"build_graph_projection_package",
|
||||
"build_graph_view_plan",
|
||||
),
|
||||
"docforge.graph_rendering": ("GraphRenderService",),
|
||||
"docforge.manual_projection": (
|
||||
"build_manual_projection_package",
|
||||
"build_manual_render_plan",
|
||||
|
|
@ -116,6 +118,7 @@ PUBLIC_IMPORTS = {
|
|||
"renderer_for",
|
||||
),
|
||||
"docforge_renderers.manual": ("ManualHtmlRenderer",),
|
||||
"docforge_renderers.graph": ("PortableGraphHtmlRenderer",),
|
||||
}
|
||||
|
||||
EXPECTED_ENTRY_POINTS = {
|
||||
|
|
@ -135,6 +138,9 @@ EXPECTED_CLI_COMMANDS = {
|
|||
"doctor",
|
||||
"filter",
|
||||
"generation-diff",
|
||||
"graph-plan",
|
||||
"graph-render",
|
||||
"graph-render-status",
|
||||
"impact",
|
||||
"info",
|
||||
"onboard",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue