vignette/scripts/test_check_clinical_crisis_review.py
2026-08-28 19:04:17 +09:00

258 lines
12 KiB
Python

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()