from __future__ import annotations import hashlib import importlib.util import json import shutil import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] CHECKER_PATH = REPO_ROOT / "scripts" / "check-clinical-crisis-review.py" SPEC = importlib.util.spec_from_file_location("clinical_crisis_review_checker", CHECKER_PATH) assert SPEC is not None and SPEC.loader is not None checker = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(checker) CONTRACT_FILES = ( Path("data/clinical/crisis-protocol-validation.json"), Path("data/clinical/crisis-protocol-validation.schema.json"), Path("data/clinical/p1-crisis-review-cases.json"), Path("data/clinical/p1-crisis-review-cases.schema.json"), ) def _read_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) def _write_json(path: Path, payload: dict) -> None: path.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() class ClinicalCrisisReviewCheckerTest(unittest.TestCase): def _fixture(self) -> Path: temp = tempfile.TemporaryDirectory() self.addCleanup(temp.cleanup) root = Path(temp.name) for relative in CONTRACT_FILES: target = root / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(REPO_ROOT / relative, target) return root def _complete( self, root: Path, status: str, decisions: list[str], ) -> tuple[dict, dict]: manifest_path = root / checker.MANIFEST_REL case_set_path = root / checker.CASE_SET_REL manifest = _read_json(manifest_path) case_set = _read_json(case_set_path) self.assertEqual(len(decisions), len(case_set["cases"])) reviewed_at = "2026-08-28" for case, decision in zip(case_set["cases"], decisions, strict=True): case["reviewer_assessment"] = { "decision": decision, "rationale": f"{case['case_id']} 외부 검토 판정", "reviewed_at": reviewed_at, } _write_json(case_set_path, case_set) evidence_path = root / "data/clinical/evidence/c-001-review.txt" evidence_path.parent.mkdir(parents=True, exist_ok=True) evidence_path.write_text("외부 검토 서면 증거 fixture\n", encoding="utf-8") manifest["clinical_status"] = status manifest["approval"] = { "reviewer": "검토자", "organization": "외부 임상기관", "reviewed_at": reviewed_at, "decision": status, "notes": None if status == "approved" else f"{status} 판정 사유", "evidence_ref": "data/clinical/evidence/c-001-review.txt", "evidence_sha256": _sha256(evidence_path), "reviewed_protocol_version": manifest["version"], "reviewed_case_set_sha256": _sha256(case_set_path), } _write_json(manifest_path, manifest) return manifest, case_set def test_repository_pending_contract_is_green(self) -> None: self.assertEqual(checker.validate_review_contract(repo_root=REPO_ROOT), []) def test_pending_requires_every_approval_and_case_assessment_value_to_be_null(self) -> None: root = self._fixture() manifest_path = root / checker.MANIFEST_REL manifest = _read_json(manifest_path) manifest["approval"]["reviewer"] = "임의 검토자" _write_json(manifest_path, manifest) case_set_path = root / checker.CASE_SET_REL case_set = _read_json(case_set_path) case_set["cases"][0]["reviewer_assessment"]["decision"] = "pass" _write_json(case_set_path, case_set) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("approval.reviewer는 null" in error for error in errors)) self.assertTrue( any("P1-CRISIS-001" in error and ".decision" in error and "null" in error for error in errors), ) def test_each_completed_status_passes_its_case_decision_contract(self) -> None: fixtures = ( ("approved", ["pass"] * 6), ("conditional", ["conditional", "pass", "pass", "pass", "pass", "pass"]), ("rejected", ["fail", "pass", "pass", "pass", "pass", "pass"]), ) for status, decisions in fixtures: with self.subTest(status=status): root = self._fixture() self._complete(root, status, decisions) self.assertEqual(checker.validate_review_contract(repo_root=root), []) def test_status_and_overall_decision_must_match(self) -> None: root = self._fixture() self._complete(root, "approved", ["pass"] * 6) manifest_path = root / checker.MANIFEST_REL manifest = _read_json(manifest_path) manifest["approval"]["decision"] = "conditional" _write_json(manifest_path, manifest) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("clinical_status=approved" in error for error in errors)) self.assertTrue(any("approval.decision은 approved" in error for error in errors)) def test_approved_rejects_any_non_pass_case(self) -> None: root = self._fixture() self._complete(root, "approved", ["conditional", "pass", "pass", "pass", "pass", "pass"]) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("모든 사례 판정이 pass" in error for error in errors)) def test_conditional_requires_conditional_and_forbids_fail(self) -> None: root_without_condition = self._fixture() self._complete(root_without_condition, "conditional", ["pass"] * 6) errors = checker.validate_review_contract(repo_root=root_without_condition) self.assertTrue(any("하나 이상의 conditional" in error for error in errors)) root_with_fail = self._fixture() self._complete(root_with_fail, "conditional", ["fail", "conditional", "pass", "pass", "pass", "pass"]) errors = checker.validate_review_contract(repo_root=root_with_fail) self.assertTrue(any("fail 사례가 있으면 rejected" in error for error in errors)) def test_rejected_requires_at_least_one_fail(self) -> None: root = self._fixture() self._complete(root, "rejected", ["conditional", "pass", "pass", "pass", "pass", "pass"]) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("하나 이상의 fail" in error for error in errors)) def test_completed_state_requires_each_case_rationale_and_matching_review_date(self) -> None: root = self._fixture() self._complete(root, "approved", ["pass"] * 6) case_set_path = root / checker.CASE_SET_REL case_set = _read_json(case_set_path) case_set["cases"][0]["reviewer_assessment"]["rationale"] = " " case_set["cases"][1]["reviewer_assessment"]["reviewed_at"] = "2026-08-27" _write_json(case_set_path, case_set) manifest_path = root / checker.MANIFEST_REL manifest = _read_json(manifest_path) manifest["approval"]["reviewed_case_set_sha256"] = _sha256(case_set_path) _write_json(manifest_path, manifest) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("P1-CRISIS-001" in error and "rationale" in error for error in errors)) self.assertTrue(any("P1-CRISIS-002" in error and "전체 검토일" in error for error in errors)) def test_evidence_must_exist_under_clinical_evidence_and_match_hash(self) -> None: root = self._fixture() self._complete(root, "approved", ["pass"] * 6) manifest_path = root / checker.MANIFEST_REL manifest = _read_json(manifest_path) manifest["approval"]["evidence_ref"] = "../outside.txt" _write_json(manifest_path, manifest) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("상위 경로 이동" in error for error in errors)) manifest["approval"]["evidence_ref"] = "data/clinical/evidence/missing.txt" _write_json(manifest_path, manifest) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("증거 파일이 없다" in error for error in errors)) manifest["approval"]["evidence_ref"] = "data/clinical/evidence/c-001-review.txt" manifest["approval"]["evidence_sha256"] = "0" * 64 _write_json(manifest_path, manifest) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("실제 증거 파일 해시" in error for error in errors)) def test_protocol_version_and_case_set_hash_are_pinned(self) -> None: root = self._fixture() self._complete(root, "approved", ["pass"] * 6) manifest_path = root / checker.MANIFEST_REL manifest = _read_json(manifest_path) manifest["approval"]["reviewed_protocol_version"] = "outdated" manifest["approval"]["reviewed_case_set_sha256"] = "0" * 64 _write_json(manifest_path, manifest) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("reviewed_protocol_version" in error for error in errors)) self.assertTrue(any("reviewed_case_set_sha256" in error for error in errors)) def test_case_set_cannot_claim_clinical_answer_or_method_detail(self) -> None: root = self._fixture() case_set_path = root / checker.CASE_SET_REL case_set = _read_json(case_set_path) case_set["content_safety"]["clinical_answer_included"] = True case_set["cases"][0]["synthetic_scenario"]["method_or_means_detail_present"] = True _write_json(case_set_path, case_set) errors = checker.validate_review_contract(repo_root=root) self.assertTrue(any("임상 정답 미포함" in error for error in errors)) self.assertTrue(any("수단·방법 상세" in error for error in errors)) def test_direct_validator_rejects_unknown_fields_without_jsonschema(self) -> None: root = self._fixture() manifest_path = root / checker.MANIFEST_REL manifest = _read_json(manifest_path) manifest["clinical_approval_override"] = True _write_json(manifest_path, manifest) original_validator = checker.Draft202012Validator original_format_checker = checker.FormatChecker checker.Draft202012Validator = None checker.FormatChecker = None try: errors = checker.validate_review_contract(repo_root=root) finally: checker.Draft202012Validator = original_validator checker.FormatChecker = original_format_checker self.assertTrue(any("manifest: 필드 불일치" in error for error in errors)) def test_cli_returns_zero_for_current_contract_and_json_summary(self) -> None: stdout = StringIO() stderr = StringIO() with redirect_stdout(stdout), redirect_stderr(stderr): result = checker.main(["--repo-root", str(REPO_ROOT)]) self.assertEqual(result, 0, stderr.getvalue()) summary = json.loads(stdout.getvalue()) self.assertTrue(summary["ok"]) self.assertEqual(summary["case_count"], 6) self.assertFalse(summary["external_review_replaced"]) if __name__ == "__main__": unittest.main()