"""다이어그램 소스(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'