diff --git a/scripts/tests/test_terminal_evidence.py b/scripts/tests/test_terminal_evidence.py new file mode 100644 index 0000000..ef93208 --- /dev/null +++ b/scripts/tests/test_terminal_evidence.py @@ -0,0 +1,42 @@ +"""`scripts/terminal-evidence/tests/` 를 문서에 적힌 한 명령이 함께 돌게 잇는다. + +`python3 -m unittest discover -s scripts/tests` 는 그 폴더만 훑는다. 터미널 증거 렌더러의 +테스트는 `scripts/terminal-evidence/tests/` 에 따로 있어서 그 명령에 안 잡혔고, +`verify-pipeline.py` 도 unittest 를 부르지 않는다. 그래서 **초록인데 아무도 안 부르는 회귀**가 됐다. + +`discover -s scripts/terminal-evidence/tests` 를 직접 돌려도 깨진다 — 그 테스트가 +`render_terminal` 을 같은 폴더 기준으로 부르는데 그 경로가 `sys.path` 에 없다. 여기서 +경로를 얹고 파일을 직접 불러 `load_tests` 로 끌어온다. 중첩 `discover` 는 쓰지 않는다 — +`top_level_dir` 이 바깥을 가리키면 unittest 가 거절한다. +""" +from __future__ import annotations + +import importlib.util +import os +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_TE = os.path.join(os.path.dirname(_HERE), "terminal-evidence") +_TE_TESTS = os.path.join(_TE, "tests") + + +def load_tests(loader: unittest.TestLoader, tests: unittest.TestSuite, + pattern: str | None) -> unittest.TestSuite: + if not os.path.isdir(_TE_TESTS): + return tests + if _TE not in sys.path: + sys.path.insert(0, _TE) + for name in sorted(os.listdir(_TE_TESTS)): + if not (name.startswith("test") and name.endswith(".py")): + continue + mod_name = "terminal_evidence_" + name[:-3] + spec = importlib.util.spec_from_file_location( + mod_name, os.path.join(_TE_TESTS, name)) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) + tests.addTests(loader.loadTestsFromModule(module)) + return tests diff --git a/scripts/verify-pipeline-run.py b/scripts/verify-pipeline-run.py index 7484f97..931b40e 100644 --- a/scripts/verify-pipeline-run.py +++ b/scripts/verify-pipeline-run.py @@ -44,16 +44,29 @@ STAGES = { "gates": ["check_body.mjs", "check_prose.mjs", "check_evidence.mjs"], "skippable": False}, "S4": {"skill": "technical-visualizer", - "gates": ["lint", "check-figure-text.py", "check-figure-overlap.py"], + "gates": ["lint", "check-figure-text.py", "check-figure-overlap.py", + "preview-figure.py"], "skippable": True}, + # S5·S6 은 문장을 고친 뒤라 S3 관문을 다시 돈다 (stage-contracts.md 「관문 요약」) "S5": {"skill": "rewriting-technical-prose-naturally", - "gates": ["check_prose.mjs", "style_profile.mjs", "check_body.mjs"], + "gates": ["check_prose.mjs", "style_profile.mjs", "check_body.mjs", + "check_evidence.mjs"], "skippable": False}, "S6": {"skill": "writing-as-the-person-who-did-it", - "gates": ["check_voice.mjs", "check_prose.mjs"], "skippable": False}, + "gates": ["check_voice.mjs", "check_prose.mjs", "check_body.mjs", + "check_evidence.mjs"], + "skippable": False}, "S7": {"skill": "publishing-tech-log-to-studio", - "gates": ["저장됨", "verify-tech-log-tree.py"], "skippable": True}, + "gates": ["저장됨", "build-tech-log-tree.py", "verify-tech-log-tree.py"], + "skippable": True}, } + +# 측정 관문 — 돌았다는 것은 요구하지만 종료 코드 0 은 요구하지 않는다. +# 문서 계약이 「error 0」을 붙인 것은 check_prose 뿐이고 style_profile 은 문체 수치를 보여 주는 +# 측정이다 (stage-contracts.md:178·:252). 여기에 0 을 요구하면 정직하게 적은 원장이 실패하고, +# 0 으로 고쳐 적으면 그건 지어낸 것이 된다. +MEASUREMENT_GATES = ("style_profile.mjs",) + ORDER = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"] @@ -130,9 +143,14 @@ def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> None if not gates: rep.error("곁증명에 관문이 없다", f"{where} — {out}") for g in gates: + cmd = str(g.get("cmd") or "") + if any(tok in cmd for tok in MEASUREMENT_GATES): + if g.get("exit") is None: + rep.error("곁증명의 측정 관문에 종료 코드가 없다", f"{where} — {cmd[:70]}") + continue if g.get("exit") not in (0, "0"): rep.error("곁증명의 관문이 통과하지 못했다", - f"{where} — {str(g.get('cmd'))[:70]} → exit {g.get('exit')}") + f"{where} — {cmd[:70]} → exit {g.get('exit')}") rep.facts.setdefault("곁증명", []).append(f"{sid}:{os.path.basename(out)}") @@ -214,9 +232,16 @@ def verify(path: str) -> Report: if token not in cmds: rep.error("관문이 빠졌다", f"{where} — {token}") for g in gates: + cmd = str(g.get("cmd") or "") + if any(tok in cmd for tok in MEASUREMENT_GATES): + # 측정 관문 — 돌았는지만 본다. exit 칸이 아예 없으면 안 돌린 것이다 + if g.get("exit") is None: + rep.error("측정 관문의 종료 코드가 없다", + f"{where} — {cmd[:70]} — 돌렸다는 기록이 없다") + continue if g.get("exit") not in (0, "0"): rep.error("관문이 통과하지 못했다", - f"{where} — {str(g.get('cmd'))[:70]} → exit {g.get('exit')}") + f"{where} — {cmd[:70]} → exit {g.get('exit')}") for out in st.get("outputs") or []: if not os.path.exists(os.path.join(ROOT, out)):