init: technical-visualization-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.cli import main
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def test_prepare_prompt_lint_render(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
context = root / "context.json"
|
||||
prompt = root / "prompt.md"
|
||||
output = root / "out"
|
||||
self.assertEqual(
|
||||
main([
|
||||
"prepare",
|
||||
str(ROOT / "examples/docs/payment-flow.md"),
|
||||
"--marker",
|
||||
"payment-request",
|
||||
"-o",
|
||||
str(context),
|
||||
]),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(main(["prompt", str(context), "-o", str(prompt)]), 0)
|
||||
self.assertIn("untrusted evidence data", prompt.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
main([
|
||||
"lint",
|
||||
str(ROOT / "examples/work/payment/spec.json"),
|
||||
"--context",
|
||||
str(context),
|
||||
]),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
main([
|
||||
"render",
|
||||
str(ROOT / "examples/work/payment/spec.json"),
|
||||
"--context",
|
||||
str(context),
|
||||
"--formats",
|
||||
"svg,a11y",
|
||||
"-o",
|
||||
str(output),
|
||||
]),
|
||||
0,
|
||||
)
|
||||
manifest = json.loads((output / "payment-request.manifest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["spec_id"], "payment-request")
|
||||
|
||||
def test_render_blocks_unapproved_assumptions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
data = json.loads((ROOT / "examples/work/payment/spec.json").read_text(encoding="utf-8"))
|
||||
data["nodes"][0]["evidence"] = []
|
||||
data["nodes"][0]["assumption"] = True
|
||||
spec = root / "assumption.json"
|
||||
spec.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
blocked_output = root / "blocked"
|
||||
self.assertEqual(
|
||||
main([
|
||||
"render",
|
||||
str(spec),
|
||||
"--context",
|
||||
str(ROOT / "examples/work/payment/context.json"),
|
||||
"--formats",
|
||||
"svg",
|
||||
"-o",
|
||||
str(blocked_output),
|
||||
]),
|
||||
1,
|
||||
)
|
||||
self.assertFalse((blocked_output / "payment-request.svg").exists())
|
||||
|
||||
approved_output = root / "approved"
|
||||
self.assertEqual(
|
||||
main([
|
||||
"render",
|
||||
str(spec),
|
||||
"--context",
|
||||
str(ROOT / "examples/work/payment/context.json"),
|
||||
"--formats",
|
||||
"svg",
|
||||
"--allow-assumptions",
|
||||
"-o",
|
||||
str(approved_output),
|
||||
]),
|
||||
0,
|
||||
)
|
||||
manifest = json.loads((approved_output / "payment-request.manifest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["assumption_count"], 1)
|
||||
self.assertTrue(manifest["assumptions_allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.document import build_context, canonicalize_document
|
||||
|
||||
|
||||
class DocumentContextTests(unittest.TestCase):
|
||||
def test_extracts_current_and_neighbor_sections(self) -> None:
|
||||
text = """# Title
|
||||
|
||||
## Before
|
||||
|
||||
before fact
|
||||
|
||||
## Target
|
||||
|
||||
target fact
|
||||
|
||||
<!-- techviz:generate id=target -->
|
||||
|
||||
## After
|
||||
|
||||
after fact
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "doc.md"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
context = build_context(path, marker_id="target")
|
||||
|
||||
self.assertEqual(context["current_section"]["heading"]["text"], "Target")
|
||||
self.assertEqual(context["previous_section"]["heading"]["text"], "Before")
|
||||
self.assertEqual(context["next_section"]["heading"]["text"], "After")
|
||||
self.assertIn("target fact", context["numbered_context"])
|
||||
self.assertEqual(context["line_number_space"], "canonical-source-with-managed-blocks-collapsed")
|
||||
|
||||
def test_generated_block_canonicalizes_to_original_marker(self) -> None:
|
||||
original = """# Title
|
||||
|
||||
## Target
|
||||
|
||||
fact
|
||||
|
||||
<!-- techviz:generate id=target -->
|
||||
|
||||
## After
|
||||
|
||||
more
|
||||
"""
|
||||
rendered = """# Title
|
||||
|
||||
## Target
|
||||
|
||||
fact
|
||||
|
||||
<!-- techviz:begin id=target context-sha256=abc -->
|
||||
<!-- techviz:generate id=target -->
|
||||

|
||||
|
||||
<details><summary>Description</summary>Long description</details>
|
||||
<!-- techviz:end id=target -->
|
||||
|
||||
## After
|
||||
|
||||
more
|
||||
"""
|
||||
self.assertEqual(canonicalize_document(rendered), original)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
original_path = Path(tmp) / "original.md"
|
||||
rendered_path = Path(tmp) / "rendered.md"
|
||||
original_path.write_text(original, encoding="utf-8")
|
||||
rendered_path.write_text(rendered, encoding="utf-8")
|
||||
original_context = build_context(original_path, marker_id="target")
|
||||
rendered_context = build_context(rendered_path, marker_id="target")
|
||||
self.assertEqual(original_context["document_sha256"], rendered_context["document_sha256"])
|
||||
self.assertEqual(original_context["numbered_context"], rendered_context["numbered_context"])
|
||||
|
||||
def test_parent_preamble_is_trimmed_before_nested_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
document = Path(tmp) / "nested.md"
|
||||
document.write_text(
|
||||
"# System\n\n"
|
||||
"## Parent\n\n"
|
||||
"Parent-level context.\n\n"
|
||||
"### Target\n\n"
|
||||
"Target details.\n\n"
|
||||
"<!-- techviz:generate id=nested -->\n\n"
|
||||
"### Following\n\n"
|
||||
"Following details.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
context = build_context(document, marker_id="nested")
|
||||
previous = context["previous_section"]
|
||||
self.assertEqual(previous["heading"]["text"], "Parent")
|
||||
self.assertLess(previous["end_line"], context["current_section"]["start_line"])
|
||||
self.assertIn("Parent-level context.", previous["text"])
|
||||
self.assertNotIn("Target details.", previous["text"])
|
||||
self.assertEqual(context["next_section"]["heading"]["text"], "Following")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.insert import build_markdown_block, insert_or_replace
|
||||
|
||||
|
||||
class InsertTests(unittest.TestCase):
|
||||
def test_insert_is_idempotent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
document = root / "doc.md"
|
||||
svg = root / "diagram.svg"
|
||||
source = root / "diagram.drawio"
|
||||
spec = root / "spec.json"
|
||||
document.write_text("# Doc\n\n<!-- techviz:generate id=diagram -->\n", encoding="utf-8")
|
||||
for path in (svg, source, spec):
|
||||
path.write_text("x", encoding="utf-8")
|
||||
block = build_markdown_block(
|
||||
diagram_id="diagram",
|
||||
alt="A useful diagram",
|
||||
long_description="The diagram explains a source-grounded path.",
|
||||
svg_path=svg,
|
||||
editable_path=source,
|
||||
spec_path=spec,
|
||||
document_path=document,
|
||||
context_sha256="abc",
|
||||
)
|
||||
insert_or_replace(document, "diagram", block)
|
||||
insert_or_replace(document, "diagram", block)
|
||||
result = document.read_text(encoding="utf-8")
|
||||
self.assertEqual(result.count("techviz:begin id=diagram"), 1)
|
||||
self.assertEqual(result.count("techviz:end id=diagram"), 1)
|
||||
self.assertEqual(result.count("techviz:generate id=diagram"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.document import build_context
|
||||
from techviz.prompt import build_agent_prompt
|
||||
from techviz.spec import validate_raw_spec
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PromptTests(unittest.TestCase):
|
||||
def test_embedded_vizspec_scaffold_is_valid_and_context_bound(self) -> None:
|
||||
context = build_context(
|
||||
ROOT / "examples/docs/payment-flow.md",
|
||||
marker_id="payment-request",
|
||||
)
|
||||
prompt = build_agent_prompt(context)
|
||||
section_start = prompt.index("## VizSpec 1.0 shape")
|
||||
json_start = prompt.index("{\n", section_start)
|
||||
json_end = prompt.index("\n\nFor a sequence diagram", json_start)
|
||||
scaffold = json.loads(prompt[json_start:json_end])
|
||||
|
||||
validate_raw_spec(scaffold)
|
||||
self.assertEqual(
|
||||
scaffold["source_context"],
|
||||
{
|
||||
"document": context["document"],
|
||||
"document_sha256": context["document_sha256"],
|
||||
"anchor": context["anchor"],
|
||||
},
|
||||
)
|
||||
self.assertFalse(scaffold["groups"])
|
||||
self.assertFalse(scaffold["legend"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from techviz.layout import DiagramLayout, EdgePath, NodeBox
|
||||
from techviz.quality import validate_layout
|
||||
from techviz.spec import VizSpec
|
||||
|
||||
|
||||
class QualityTests(unittest.TestCase):
|
||||
def test_detects_edge_passing_through_unrelated_node(self) -> None:
|
||||
spec = VizSpec.from_dict(
|
||||
{
|
||||
"version": "1.0",
|
||||
"id": "quality-case",
|
||||
"title": "Quality case",
|
||||
"question": "Does an edge cross a node?",
|
||||
"type": "architecture",
|
||||
"direction": "LR",
|
||||
"summary": "Synthetic quality test.",
|
||||
"alt": "Synthetic diagram with three nodes.",
|
||||
"long_description": "A line from A to C passes through unrelated node B.",
|
||||
"source_context": {},
|
||||
"groups": [],
|
||||
"nodes": [
|
||||
{"id": "a", "label": "A", "kind": "service", "evidence": [], "assumption": True},
|
||||
{"id": "b", "label": "B", "kind": "service", "evidence": [], "assumption": True},
|
||||
{"id": "c", "label": "C", "kind": "service", "evidence": [], "assumption": True}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "a-to-c", "from": "a", "to": "c", "label": "calls", "kind": "request", "evidence": [], "assumption": True}
|
||||
]
|
||||
}
|
||||
)
|
||||
layout = DiagramLayout(
|
||||
width=500,
|
||||
height=240,
|
||||
nodes={
|
||||
"a": NodeBox("a", 20, 80, 100, 60, ["A"]),
|
||||
"b": NodeBox("b", 190, 80, 100, 60, ["B"]),
|
||||
"c": NodeBox("c", 360, 80, 100, 60, ["C"]),
|
||||
},
|
||||
groups={},
|
||||
edges={
|
||||
"a-to-c": EdgePath("a-to-c", [(120, 110), (360, 110)], 240, 70)
|
||||
},
|
||||
)
|
||||
issues = validate_layout(spec, layout)
|
||||
self.assertTrue(any(issue.code == "edge-through-node" for issue in issues))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.layout import build_layout
|
||||
from techviz.renderers import render_formats
|
||||
from techviz.spec import load_spec
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RendererTests(unittest.TestCase):
|
||||
def test_all_builtin_outputs_are_parseable(self) -> None:
|
||||
spec = load_spec(ROOT / "examples/work/payment/spec.json")
|
||||
layout = build_layout(spec)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outputs = render_formats(
|
||||
spec,
|
||||
layout,
|
||||
tmp,
|
||||
["svg", "mermaid", "d2", "dot", "drawio", "excalidraw", "a11y"],
|
||||
)
|
||||
by_suffix = {path.suffix: path for path in outputs}
|
||||
ET.parse(by_suffix[".svg"])
|
||||
ET.parse(by_suffix[".drawio"])
|
||||
excalidraw = json.loads(by_suffix[".excalidraw"].read_text(encoding="utf-8"))
|
||||
self.assertEqual(excalidraw["type"], "excalidraw")
|
||||
self.assertGreater(len(excalidraw["elements"]), 0)
|
||||
self.assertIn("flowchart LR", by_suffix[".mmd"].read_text(encoding="utf-8"))
|
||||
self.assertIn("direction: right", by_suffix[".d2"].read_text(encoding="utf-8"))
|
||||
self.assertIn("digraph techviz", by_suffix[".dot"].read_text(encoding="utf-8"))
|
||||
alt_file = next(path for path in outputs if path.name.endswith(".alt.md"))
|
||||
self.assertIn("## Long description", alt_file.read_text(encoding="utf-8"))
|
||||
|
||||
def test_layout_is_deterministic(self) -> None:
|
||||
spec = load_spec(ROOT / "examples/work/payment/spec.json")
|
||||
first = build_layout(spec)
|
||||
second = build_layout(spec)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.spec import load_spec
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class SpecParsingTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.data = json.loads((ROOT / "examples/work/payment/spec.json").read_text(encoding="utf-8"))
|
||||
|
||||
def _write(self, data: dict) -> Path:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
path = Path(self.temp.name) / "spec.json"
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def tearDown(self) -> None:
|
||||
temp = getattr(self, "temp", None)
|
||||
if temp is not None:
|
||||
temp.cleanup()
|
||||
|
||||
def test_boolean_fields_are_not_coerced_from_strings(self) -> None:
|
||||
self.data["nodes"][0]["assumption"] = "false"
|
||||
with self.assertRaisesRegex(ValueError, "expected boolean"):
|
||||
load_spec(self._write(self.data))
|
||||
|
||||
def test_unknown_model_output_fields_are_rejected(self) -> None:
|
||||
self.data["nodes"][0]["visual_magic"] = "glow"
|
||||
with self.assertRaisesRegex(ValueError, "unknown field"):
|
||||
load_spec(self._write(self.data))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from techviz.spec import VizSpec
|
||||
from techviz.validate import has_errors, validate_spec
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.spec_data = json.loads((ROOT / "examples/work/payment/spec.json").read_text(encoding="utf-8"))
|
||||
self.context = json.loads((ROOT / "examples/work/payment/context.json").read_text(encoding="utf-8"))
|
||||
|
||||
def test_example_is_clean(self) -> None:
|
||||
issues = validate_spec(VizSpec.from_dict(self.spec_data), self.context)
|
||||
self.assertFalse(has_errors(issues), issues)
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
def test_ungrounded_node_is_error(self) -> None:
|
||||
data = deepcopy(self.spec_data)
|
||||
data["nodes"][0]["evidence"] = []
|
||||
issues = validate_spec(VizSpec.from_dict(data), self.context)
|
||||
self.assertTrue(any(issue.code == "ungrounded-element" and issue.path == "nodes[0]" for issue in issues))
|
||||
|
||||
def test_boundary_requires_evidence(self) -> None:
|
||||
data = deepcopy(self.spec_data)
|
||||
data["groups"] = [
|
||||
{
|
||||
"id": "trust-boundary",
|
||||
"label": "Trust boundary",
|
||||
"kind": "trust",
|
||||
"evidence": [],
|
||||
"assumption": False
|
||||
}
|
||||
]
|
||||
data["nodes"][1]["group"] = "trust-boundary"
|
||||
issues = validate_spec(VizSpec.from_dict(data), self.context)
|
||||
self.assertTrue(any(issue.code == "ungrounded-element" and issue.path == "groups[0]" for issue in issues))
|
||||
|
||||
def test_explicit_assumption_is_warning_not_grounding_error(self) -> None:
|
||||
data = deepcopy(self.spec_data)
|
||||
data["nodes"][0]["evidence"] = []
|
||||
data["nodes"][0]["assumption"] = True
|
||||
issues = validate_spec(VizSpec.from_dict(data), self.context)
|
||||
self.assertFalse(any(issue.code == "ungrounded-element" and issue.path == "nodes[0]" for issue in issues))
|
||||
self.assertTrue(any(issue.code == "explicit-assumption" for issue in issues))
|
||||
|
||||
def test_assumption_cannot_also_claim_evidence(self) -> None:
|
||||
data = deepcopy(self.spec_data)
|
||||
data["nodes"][0]["assumption"] = True
|
||||
issues = validate_spec(VizSpec.from_dict(data), self.context)
|
||||
self.assertTrue(any(issue.code == "assumption-with-evidence" for issue in issues))
|
||||
|
||||
def test_indirect_group_cycle_is_error(self) -> None:
|
||||
data = deepcopy(self.spec_data)
|
||||
data["groups"] = [
|
||||
{
|
||||
"id": "a", "label": "A", "kind": "system", "parent": "b",
|
||||
"evidence": [{"start_line": 7, "end_line": 7}], "assumption": False,
|
||||
},
|
||||
{
|
||||
"id": "b", "label": "B", "kind": "system", "parent": "a",
|
||||
"evidence": [{"start_line": 7, "end_line": 7}], "assumption": False,
|
||||
},
|
||||
]
|
||||
issues = validate_spec(VizSpec.from_dict(data), self.context)
|
||||
self.assertTrue(any(issue.code == "recursive-group" for issue in issues))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user