Files
document-haness/scripts/tests/test_tech_log_tree.py
T

459 lines
22 KiB
Python

#!/usr/bin/env python3
"""글감 계약과 폴더 배치를 작은 픽스처로 확인한다.
python3 -m unittest discover -s scripts/tests
"""
from __future__ import annotations
import contextlib
import copy
import hashlib
import importlib.util
import io
import json
import os
import sys
import tempfile
import unittest
SCRIPTS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, SCRIPTS)
import techlog # noqa: E402
def _load(name: str, filename: str):
spec = importlib.util.spec_from_file_location(name, os.path.join(SCRIPTS, filename))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
verifier = _load("verify_tech_log_tree", "verify-tech-log-tree.py")
builder = _load("build_tech_log_tree", "build-tech-log-tree.py")
layout = _load("verify_project_layout", "verify-project-layout.py")
CASE = {
"title": "세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다",
"kind": "case", "slug": "session-split-across-nodes", "readiness": "READY",
"source": ["final/document.md#4-2"], "code": ["SessionStore.java:41"],
"evidence": ["evidence/raw/session-probe.txt"],
"classification": "두 노드에 요청을 번갈아 보내 재현했고 로그로 확인했다",
"missing-verification": "없음",
"relations": ["concept:authorization-code-exchange"],
}
CONCEPT = {
"title": "Authorization Code 교환이 한 번 더 일어나는 자리",
"kind": "concept", "slug": "authorization-code-exchange", "readiness": "READY",
"source": ["final/document.md#3-1"], "basis-version": "Keycloak 26.7.0",
"classification": "이 교환을 알아야 아래 Case 의 관측을 읽을 수 있다",
"relations": ["case:session-split-across-nodes"],
}
INDEX = {
"schemaVersion": 4, "project": "fixture", "ssot": "final/document.md",
"sourceRevision": "abc1234", "generatedAt": "2026-09-05",
"candidateScope": {"document": "final/document.md", "sections": ["§3", "§11"]},
"sourceRepository": {"path": "https://example.invalid/fixture.git",
"revision": "0" * 40, "verified": "픽스처"},
"contract": {"readinessValues": techlog.READINESS},
"topics": {
"session-custody": {
"topic": "session-custody", "title": "세션을 누가 보관하는가",
"readerQuestion": "자격증명과 세션을 누가 보관하고 보호 자원은 무엇을 신뢰하는가?",
"kinds": {"case": [CASE], "concept": [CONCEPT],
"reference": [], "question": [], "decision": []},
}
},
"candidates": [
{"id": "F001", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
"target": "case:session-split-across-nodes"},
{"id": "F002", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
"target": "concept:authorization-code-exchange"},
{"id": "F003", "disposition": "KEEP_IN_SSOT", "dispositionReview": "CONFIRMED"},
],
}
RECORD = """\
---
kind: CASE
slug: session-split-across-nodes
title: 세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다
topic: session-custody
project: fixture
status: 게시 전
---
"""
class Fixture:
def __init__(self, index: dict | None = None, with_record: bool = True) -> None:
self.dir = tempfile.TemporaryDirectory()
self.root = self.dir.name
self.base = os.path.join(self.root, "docs/fixture")
self.studio = os.path.join(self.base, "tech-log-studio")
os.makedirs(os.path.join(self.studio, "session-custody/case"))
os.makedirs(os.path.join(self.base, "final"))
ssot = os.path.join(self.base, "final/document.md")
open(ssot, "w", encoding="utf-8").write("# fixture\n")
data = copy.deepcopy(index if index is not None else INDEX)
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
self.index_path = os.path.join(self.studio, "tech-log-tree.json")
self.write(data)
if with_record:
open(os.path.join(self.studio, "session-custody/case/case-session-split.md"),
"w", encoding="utf-8").write(RECORD)
def write(self, data: dict) -> None:
with open(self.index_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
def read(self) -> dict:
return json.load(open(self.index_path, encoding="utf-8"))
def diagram(self, name: str, *, bundled: bool = True, with_source: bool = True,
cited: bool = False) -> None:
assets = os.path.join(self.base, "final/assets")
target = os.path.join(assets, name) if bundled else assets
os.makedirs(target, exist_ok=True)
open(os.path.join(target, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
if with_source:
src = os.path.join(self.base, "final/.techviz", name)
os.makedirs(src, exist_ok=True)
open(os.path.join(src, "spec.json"), "w", encoding="utf-8").write("{}")
if cited:
self.cite(name)
def cite(self, name: str) -> None:
"""기록이 그림을 가리키게 한다. 사본을 만들지 않고 그림이 사는 자리를 그대로 쓴다."""
record = os.path.join(self.studio, "session-custody/case/case-session-split.md")
open(record, "w", encoding="utf-8").write(RECORD.replace(
"status: 게시 전\n",
f"status: 게시 전\nassets:\n - key: {name}\n"
f" file: ../../../final/assets/{name}/{name}.svg\n"))
def __enter__(self):
self._saved = (verifier.ROOT, builder.ROOT, layout.ROOT)
verifier.ROOT = builder.ROOT = layout.ROOT = self.root
return self
def __exit__(self, *exc):
verifier.ROOT, builder.ROOT, layout.ROOT = self._saved
self.dir.cleanup()
def mutate(**changes):
"""글감 하나의 칸을 바꾼 색인을 만든다."""
index = copy.deepcopy(INDEX)
case = index["topics"]["session-custody"]["kinds"]["case"][0]
for key, value in changes.items():
if value is None:
case.pop(key, None)
else:
case[key] = value
return index
class ContractTest(unittest.TestCase):
def test_clean_fixture_has_no_errors(self):
with Fixture():
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
report = verifier.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
def test_missing_reader_question_is_an_error(self):
index = copy.deepcopy(INDEX)
index["topics"]["session-custody"]["readerQuestion"] = ""
with Fixture(index):
self.assertIn("Topic 에 독자 질문이 없다", verifier.verify("fixture").errors)
def test_concept_without_basis_version_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["topics"]["session-custody"]["kinds"]["concept"][0]["basis-version"]
with Fixture(index):
self.assertIn("CONCEPT 노드에 `basis-version` 가 없다",
verifier.verify("fixture").errors)
def test_case_without_classification_is_an_error(self):
with Fixture(mutate(classification=None)):
self.assertIn("CASE 노드에 `classification` 가 없다",
verifier.verify("fixture").errors)
def test_source_anchored_outside_the_ssot_is_flagged(self):
with Fixture(mutate(source=["analysis/05-persistence.md §3.5"])):
self.assertIn("근거가 SSOT 밖에만 있다", verifier.verify("fixture").warns)
def test_readiness_is_not_publication(self):
with Fixture(mutate(readiness="NEEDS_EVIDENCE")):
self.assertIn("글을 쓰면 안 되는 readiness 인데 기록이 있다",
verifier.verify("fixture").errors)
def test_unknown_readiness_is_an_error(self):
with Fixture(mutate(readiness="REJECTED")):
self.assertIn("readiness 값이 계약에 없다", verifier.verify("fixture").errors)
def test_record_outside_the_contract_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "orphan-topic/concept"))
open(os.path.join(fx.studio, "orphan-topic/concept/c.md"), "w",
encoding="utf-8").write("---\nkind: CONCEPT\nslug: nobody-listed-me\n---\n")
self.assertIn("계약에 없는 기록", verifier.verify("fixture").errors)
def test_stale_ssot_hash_is_an_error(self):
with Fixture() as fx:
open(os.path.join(fx.base, "final/document.md"), "a",
encoding="utf-8").write("바뀌었다\n")
self.assertIn("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
verifier.verify("fixture").errors)
def test_pending_disposition_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["dispositionReview"] = "PENDING"
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("disposition 을 다시 판정하지 않은 후보", report.errors)
self.assertNotIn("disposition 을 다시 판정하지 않은 후보", report.warns)
def test_promote_without_a_node_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["target"] = "case:never-written"
with Fixture(index):
self.assertIn("PROMOTE 후보가 글감에 없다", verifier.verify("fixture").errors)
def test_node_without_a_promote_candidate_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["disposition"] = "KEEP_IN_SSOT"
index["candidates"][0]["target"] = None
with Fixture(index):
self.assertIn("글감을 낳은 PROMOTE 후보가 없다",
verifier.verify("fixture").errors)
def test_missing_candidate_scope_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["candidateScope"]
with Fixture(index):
self.assertIn("candidateScope 가 없다", verifier.verify("fixture").errors)
def test_candidate_scope_pointing_at_another_document_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidateScope"]["document"] = "analysis/05-persistence.md"
with Fixture(index):
self.assertIn("candidateScope.document 가 ssot 과 다르다",
verifier.verify("fixture").errors)
def test_a_node_sourced_only_outside_the_candidate_scope_is_an_error(self):
index = mutate(source=["final/document.md#a19-messaging-runtime-core"])
index["candidateScope"]["excludedAnchorPattern"] = r"#a\d+-"
with Fixture(index):
self.assertIn("후보를 찾는 범위 밖에서만 나온 글감",
verifier.verify("fixture").errors)
def test_a_project_without_the_contract_is_an_error(self):
index = copy.deepcopy(INDEX)
index["schemaVersion"] = 2
del index["contract"]
del index["candidateScope"]
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("글감 계약을 아직 쓰지 않았다", report.errors)
self.assertNotIn("글감 계약을 아직 쓰지 않았다", report.warns)
def test_an_old_index_is_not_flooded_with_per_field_errors(self):
index = copy.deepcopy(INDEX)
index["schemaVersion"] = 2
del index["contract"]
del index["candidateScope"]
index["topics"]["session-custody"]["readerQuestion"] = ""
for kind in ("case", "concept"):
for node in index["topics"]["session-custody"]["kinds"][kind]:
node.pop("classification", None)
node.pop("basis-version", None)
with Fixture(index):
rules = set(verifier.verify("fixture").errors)
self.assertEqual(
{r for r in rules if "노드에" in r or "독자 질문" in r}, set(),
"계약을 안 쓴 프로젝트에 칸마다 error 를 내면 안 된다")
def test_a_tree_without_the_source_repository_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["sourceRepository"]
with Fixture(index):
self.assertIn("sourceRepository.path 가 없다", verifier.verify("fixture").errors)
def test_a_repository_without_a_pinned_revision_is_a_warning(self):
index = copy.deepcopy(INDEX)
index["sourceRepository"]["revision"] = None
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("sourceRepository 에 리비전이 없다", report.warns)
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
def test_branch_tips_count_as_a_pinned_revision(self):
index = copy.deepcopy(INDEX)
index["sourceRepository"]["revision"] = None
index["sourceRepository"]["revisions"] = {"pattern1": "0" * 40, "pattern2": "1" * 40}
with Fixture(index):
report = verifier.verify("fixture")
self.assertNotIn("sourceRepository 에 리비전이 없다", report.warns)
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
def test_duplicate_slug_is_an_error(self):
index = copy.deepcopy(INDEX)
index["topics"]["session-custody"]["kinds"]["concept"][0]["slug"] = \
"session-split-across-nodes"
with Fixture(index):
self.assertIn("slug 가 두 글감에 있다", verifier.verify("fixture").errors)
class SsotAssetTest(unittest.TestCase):
"""SSOT 가 이미 그린 그림을 글감에 배정하고, 기록이 그것을 쓰는지 본다."""
def _verify(self, fx):
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
return verifier.verify("fixture")
def test_assigned_diagram_the_record_uses_is_clean(self):
with Fixture(mutate(**{"ssot-assets": ["session-custody-map"]})) as fx:
fx.diagram("session-custody-map", cited=True)
self.assertEqual(self._verify(fx).errors, {})
def test_assigned_diagram_the_record_never_uses_is_an_error(self):
with Fixture(mutate(**{"ssot-assets": ["session-custody-map"]})) as fx:
fx.diagram("session-custody-map")
self.assertIn("배정한 SSOT 그림을 기록이 쓰지 않는다", self._verify(fx).errors)
def test_assigned_diagram_that_does_not_exist_is_an_error(self):
with Fixture(mutate(**{"ssot-assets": ["never-drawn"]})) as fx:
self.assertIn("배정한 SSOT 그림이 final/assets 에 없다", self._verify(fx).errors)
def test_assigned_evidence_the_record_never_cites_is_an_error(self):
with Fixture(mutate(**{"ssot-evidence": ["raw/explain/plan-a.txt"]})) as fx:
raw = os.path.join(fx.base, "final/evidence/raw/explain")
os.makedirs(raw)
open(os.path.join(raw, "plan-a.txt"), "w", encoding="utf-8").write("EXPLAIN\n")
self.assertIn("배정한 SSOT 증거를 기록이 쓰지 않는다", self._verify(fx).errors)
class BuildTest(unittest.TestCase):
def test_derived_fields_come_from_the_record_file(self):
with Fixture():
index, warnings = builder.build("fixture")
case = index["topics"]["session-custody"]["kinds"]["case"][0]
self.assertEqual(case["file"], "session-custody/case/case-session-split.md")
self.assertEqual(case["publication"], "초안")
self.assertEqual(case["readiness"], "READY", "readiness 는 사람이 적는다")
concept = index["topics"]["session-custody"]["kinds"]["concept"][0]
self.assertEqual(concept["publication"], "미작성")
self.assertNotIn("file", concept)
self.assertEqual(index["counts"]["written"], 1)
self.assertEqual(warnings, [])
def test_human_written_fields_survive_a_rebuild(self):
with Fixture() as fx:
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
builder.main(["build", "fixture"])
case = fx.read()["topics"]["session-custody"]["kinds"]["case"][0]
self.assertEqual(case["classification"], CASE["classification"])
self.assertEqual(case["relations"], CASE["relations"])
def test_directory_left_behind_does_not_become_a_topic(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "deleted-from-the-contract/case"))
open(os.path.join(fx.studio, "deleted-from-the-contract/case/x.md"), "w",
encoding="utf-8").write("---\nkind: CASE\nslug: revived-by-its-folder\n---\n")
index, warnings = builder.build("fixture")
self.assertEqual(set(index["topics"]), {"session-custody"})
self.assertEqual(index["unlisted"], ["deleted-from-the-contract/case/x.md"])
self.assertTrue(warnings)
class LayoutTest(unittest.TestCase):
def test_a_diagram_with_its_source_is_clean(self):
with Fixture() as fx:
fx.diagram("session-custody-map", cited=True)
report = layout.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
self.assertEqual(report.warns, {}, report.warns)
def test_ssot_diagram_no_record_cites_is_counted(self):
# 배정하지 않은 그림은 글을 쓸 때 새로 그리게 된다. keycloak 이 그렇게 됐다
with Fixture() as fx:
fx.diagram("session-custody-map")
self.assertIn("기록이 쓰지 않는 SSOT 그림", layout.verify("fixture").warns)
def test_a_project_with_no_record_yet_is_not_counted(self):
# 아직 안 쓴 것이지 안 쓰기로 한 것이 아니다
with Fixture(with_record=False) as fx:
fx.diagram("session-custody-map")
self.assertNotIn("기록이 쓰지 않는 SSOT 그림", layout.verify("fixture").warns)
def test_svg_without_a_techviz_source_is_counted(self):
with Fixture() as fx:
fx.diagram("hand-drawn", with_source=False)
self.assertIn("techviz 정본이 없는 그림", layout.verify("fixture").warns)
def test_a_figure_that_is_really_a_table_is_counted(self):
# 관계선이 없고 항목마다 같은 수의 details 면 표다. 표를 그림으로 그리지 않는다
with Fixture() as fx:
fx.diagram("distribution-choice")
spec = os.path.join(fx.base, "final/.techviz/distribution-choice/spec.json")
with open(spec, "w", encoding="utf-8") as fh:
json.dump({"nodes": [{"id": "a", "details": ["형태", "선택"]},
{"id": "b", "details": ["형태", "선택"]}],
"edges": []}, fh)
self.assertIn("표로 되는 그림", layout.verify("fixture").warns)
def test_a_figure_with_relations_is_not_a_table(self):
with Fixture() as fx:
fx.diagram("request-path", cited=True)
spec = os.path.join(fx.base, "final/.techviz/request-path/spec.json")
with open(spec, "w", encoding="utf-8") as fh:
json.dump({"nodes": [{"id": "a", "details": ["형태"]},
{"id": "b", "details": ["형태"]}],
"edges": [{"from": "a", "to": "b"}]}, fh)
self.assertNotIn("표로 되는 그림", layout.verify("fixture").warns)
def test_record_figure_without_a_techviz_source_is_counted(self):
# 사본을 가리키면 그 그림은 다시 만들 수 없다. 정본이 둘이 되는 자리다
with Fixture() as fx:
fx.diagram("hand-drawn", with_source=False, cited=True)
self.assertIn("기록이 가리키는 그림에 techviz 정본이 없다",
layout.verify("fixture").warns)
def test_evidence_folder_outside_the_convention_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "final/evidence/screenshots"))
self.assertIn("evidence 하위 폴더 이름이 규약 밖이다",
layout.verify("fixture").errors)
def test_finished_analysis_must_not_leave_working_material(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "analysis"))
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
json.dump({"analysisStatus": "COMPLETE"}, fh)
self.assertIn("분석이 끝났는데 작업 재료가 남아 있다",
layout.verify("fixture").warns)
def test_analysis_in_progress_is_not_debt(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "analysis"))
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
json.dump({"analysisStatus": "IN_PROGRESS"}, fh)
report = layout.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
self.assertEqual(report.warns, {}, report.warns)
def test_import_source_left_behind_is_counted(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "source/docs"))
open(os.path.join(fx.base, "source/docs/lab.md"), "w",
encoding="utf-8").write("원본\n")
self.assertIn("반입 원본이 남아 있다", layout.verify("fixture").warns)
if __name__ == "__main__":
unittest.main()