#!/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("") 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: """그림을 Studio 자리로 복사하고 기록이 그것을 가리키게 한다 — 실제 작업 순서다.""" studio_assets = os.path.join(self.base, "final/assets/tech-log-studio") os.makedirs(studio_assets, exist_ok=True) open(os.path.join(studio_assets, f"{name}.svg"), "w", encoding="utf-8").write("") 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/tech-log-studio/{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_studio_presentation_copies_need_no_source(self): with Fixture() as fx: assets = os.path.join(fx.base, "final/assets/tech-log-studio") os.makedirs(assets) open(os.path.join(assets, "custody.svg"), "w", encoding="utf-8").write("") self.assertEqual(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()