from __future__ import annotations import tempfile import unittest from dataclasses import replace from pathlib import Path from docforge.adapter_contract import ( AdapterEdge, AdapterNode, AdapterProject, AdapterProjection, ShadowArtifact, compare_artifacts, validate_projection, ) from docforge.errors import DocForgeError from docforge.index import ProjectIndex from docforge.models import Edge, Node class Loader: def __init__(self, projection: AdapterProjection) -> None: self.projection = projection def load_projection(self) -> AdapterProjection: return self.projection class AdapterContractTests(unittest.TestCase): def projection(self, root: Path) -> AdapterProjection: foundation = Node( node_id="guide.foundation", title="Foundation", family="guide", authority="authoritative", status="active", tags=("guide",), summary="The base contract.", content="Foundation content.", source_path="docs/foundation.md", source_anchor=None, content_hash="1" * 64, ) workflow = Node( node_id="guide.workflow", title="Workflow", family="guide", authority="approved_plan", status="planned", tags=("guide", "workflow"), summary="The editing workflow.", content="Workflow content.", source_path="docs/workflow.md", source_anchor=None, content_hash="2" * 64, ) return AdapterProjection( project_id="adapter-fixture", title="Adapter fixture", adapter_id="fixture-shadow", adapter_version="1", root=root, revision="fixture-revision", source_hash="3" * 64, nodes=( AdapterNode(foundation, (("acceptance", "proven"),)), AdapterNode(workflow, (("acceptance", "pending"),)), ), edges=( AdapterEdge( Edge("guide.workflow", "depends_on", "guide.foundation"), (("source", "docs/workflow.md"),), ), ), ) def test_adapter_projection_builds_and_checks_through_standard_index(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() projection = self.projection(root) project = AdapterProject(Loader(projection), cache_root=root / ".cache" / "shadow") index = ProjectIndex(project) built = index.build() checked = index.check() self.assertEqual("fixture-shadow@1", built["adapter"]) self.assertEqual(2, checked["node_count"]) self.assertEqual("Workflow", index.get_node("guide.workflow")["node"]["title"]) self.assertEqual(projection.identity(), projection.identity()) def test_projection_rejects_unsorted_metadata_graph_and_identity_changes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() projection = self.projection(root) invalid_metadata = replace( projection, nodes=( replace( projection.nodes[0], metadata=(("z", "last"), ("a", "first")), ), projection.nodes[1], ), ) with self.assertRaisesRegex(DocForgeError, "metadata keys"): validate_projection(invalid_metadata) invalid_source = replace( projection, nodes=( replace( projection.nodes[0], node=replace(projection.nodes[0].node, source_path="../outside.md"), ), projection.nodes[1], ), ) with self.assertRaisesRegex(DocForgeError, "source path"): validate_projection(invalid_source) broken = replace( projection, edges=(AdapterEdge(Edge("guide.workflow", "depends_on", "missing.node")),), ) with self.assertRaisesRegex(DocForgeError, "missing nodes"): validate_projection(broken) loader = Loader(projection) project = AdapterProject(loader, cache_root=root / ".cache" / "shadow") loader.projection = replace(projection, adapter_version="2") with self.assertRaisesRegex(DocForgeError, "identity changed"): project.load() def test_adapter_cache_must_remain_inside_project(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() outside = root.parent / "outside-adapter-cache" with self.assertRaisesRegex(DocForgeError, "confined"): AdapterProject(Loader(self.projection(root)), cache_root=outside) def test_artifact_comparison_is_complete_and_byte_exact(self) -> None: reference = ( ShadowArtifact("manual", b"same"), ShadowArtifact("timeline", b"old"), ) exact = compare_artifacts(reference, reference) self.assertEqual("ok", exact["status"]) self.assertEqual(2, exact["count"]) mismatch = compare_artifacts( reference, ( ShadowArtifact("timeline", b"new"), ShadowArtifact("extra", b"extra"), ), ) self.assertEqual("mismatch", mismatch["status"]) self.assertEqual(["manual"], mismatch["missing"]) self.assertEqual(["extra"], mismatch["unexpected"]) self.assertEqual(["timeline"], mismatch["changed"]) if __name__ == "__main__": unittest.main()