"""다이어그램 소스(JSON)와 산출물(HTML)이 갈라지지 않았는지 검사하는 커밋 게이트. `docs/diagrams/*.json` 이 소스이고 `*.html` 이 archify deliver 산출물이다. 둘의 동기화를 확인하는 자동 장치가 없어서, spec 만 고치고 재생성을 잊거나 HTML 을 직접 고쳐도 커밋이 통과했다. 폐쇄망 납품물에 동봉되는 문서라 고객이 보는 그림과 실제 코드가 갈라져도 검출 경로가 없었다. 이 테스트는 **브라우저도 Node 도 archify 도 필요 없다** — 순수 파일 비교다. archify deliver 가 바이트 결정적이라는 사실(같은 spec 에서 항상 같은 HTML)이 그 근거이고, 그래서 수신증 대조만으로 충분하다. 실패했을 때 고치는 법은 manifest 를 손으로 맞추는 게 아니라 `scripts/rebuild-diagrams.ps1` 를 돌리는 것이다. """ from __future__ import annotations import hashlib import json import re from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] DIAGRAM_DIR = ROOT / "docs" / "diagrams" MANIFEST = DIAGRAM_DIR / "manifest.json" # spec 의 어느 배열이 노드를 담는지는 다이어그램 타입마다 다르다. NODE_COLLECTION = { "architecture": "components", "dataflow": "nodes", "workflow": "nodes", "lifecycle": "states", } def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def _manifest() -> dict: return json.loads(MANIFEST.read_text(encoding="utf-8")) def _entries() -> list[dict]: return _manifest()["diagrams"] def _ids() -> list[str]: return [entry["html"] for entry in _entries()] def test_manifest_covers_every_delivered_diagram(): """새 다이어그램을 추가하면 수신증에도 등재해야 한다.""" # 사이드카(*.visual-check.html)는 검증 증거이지 납품물이 아니다. delivered = { path.name for path in DIAGRAM_DIR.glob("*.html") if ".visual-check." not in path.name } assert delivered == set(_ids()), ( "manifest.json 과 실제 산출물 목록이 다르다. " "다이어그램을 추가/제거했으면 scripts/rebuild-diagrams.ps1 로 갱신한다." ) @pytest.mark.parametrize("entry", _entries(), ids=_ids()) def test_html_matches_its_receipt(entry: dict): """HTML 을 직접 고치면 여기서 막힌다.""" html = DIAGRAM_DIR / entry["html"] assert html.exists(), f"{entry['html']} 이 없다" assert html.stat().st_size == entry["htmlBytes"] assert _sha256(html) == entry["htmlSha256"], ( f"{entry['html']} 이 수신증과 다르다. HTML 을 직접 고쳤거나 재생성 후 " "manifest 를 갱신하지 않았다 — scripts/rebuild-diagrams.ps1 로 재생성한다." ) @pytest.mark.parametrize("entry", _entries(), ids=_ids()) def test_spec_matches_its_receipt(entry: dict): """spec 만 고치고 재생성을 잊으면 여기서 막힌다.""" spec = DIAGRAM_DIR / entry["spec"] assert spec.exists(), f"{entry['spec']} 이 없다" assert _sha256(spec) == entry["specSha256"], ( f"{entry['spec']} 이 수신증과 다르다. spec 을 고쳤으면 " "scripts/rebuild-diagrams.ps1 로 HTML 을 재생성해야 한다." ) @pytest.mark.parametrize("entry", _entries(), ids=_ids()) def test_every_spec_node_is_present_in_the_html(entry: dict): """sha 대조가 통과해도 이 검사가 소스↔산출물의 의미적 연결을 잡는다. 해시는 '둘 다 최신인가'만 말해준다. 이 검사는 산출물이 정말 그 spec 에서 나왔는지를 본다 — 노드 id 는 HTML 에 data-node-id 로 그대로 박힌다. """ spec = json.loads((DIAGRAM_DIR / entry["spec"]).read_text(encoding="utf-8")) html = (DIAGRAM_DIR / entry["html"]).read_text(encoding="utf-8") collection = NODE_COLLECTION[entry["type"]] node_ids = [node["id"] for node in spec[collection]] assert node_ids, f"{entry['spec']} 에 노드가 없다" missing = [ node_id for node_id in node_ids if f'data-node-id="{node_id}"' not in html ] assert not missing, ( f"{entry['html']} 에 spec 노드 {missing} 가 없다 — 재생성되지 않은 산출물이다." ) def test_generator_version_is_recorded(): """산출물은 archify 버전에 따라 바이트가 달라진다. 그 버전이 기록돼 있어야 나중에 '코드는 그대로인데 해시가 바뀐' 원인을 구분할 수 있다.""" recorded = _manifest()["generator"] stamped = re.search( r'