351 lines
16 KiB
Python
351 lines
16 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):
|
|
requires_change = decision in {"conditional", "fail"}
|
|
case["reviewer_assessment"] = {
|
|
"decision": decision,
|
|
"rationale": f"{case['case_id']} 외부 검토 판정",
|
|
"findings_and_required_changes": (
|
|
"안전 전환 문구를 보강해야 함" if requires_change else "수정 요구 없음"
|
|
),
|
|
"re_review": (
|
|
"2026-09-04 수정본 제출 후 재검토"
|
|
if requires_change
|
|
else "재검토 불필요"
|
|
),
|
|
"reviewed_at": reviewed_at,
|
|
}
|
|
case_set["youth_review_responses"] = {
|
|
field: f"{field} 외부 검토 답변"
|
|
for field in checker.YOUTH_REVIEW_RESPONSE_FIELDS
|
|
}
|
|
_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": "외부 임상기관",
|
|
"reviewer_role_or_qualification": "임상 검토 자격 fixture",
|
|
"reviewed_at": reviewed_at,
|
|
"decision": status,
|
|
"notes": 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"
|
|
case_set["cases"][0]["reviewer_assessment"]["findings_and_required_changes"] = "없음"
|
|
case_set["youth_review_responses"]["guardian_notification"] = "답변"
|
|
_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),
|
|
)
|
|
self.assertTrue(
|
|
any(
|
|
"P1-CRISIS-001" in error
|
|
and ".findings_and_required_changes" in error
|
|
and "null" in error
|
|
for error in errors
|
|
),
|
|
)
|
|
self.assertTrue(
|
|
any("youth_review_responses.guardian_notification" 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"
|
|
case_set["cases"][2]["reviewer_assessment"]["findings_and_required_changes"] = None
|
|
case_set["cases"][3]["reviewer_assessment"]["re_review"] = " "
|
|
case_set["youth_review_responses"]["safeguarding"] = None
|
|
_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))
|
|
self.assertTrue(
|
|
any("P1-CRISIS-003" in error and "findings_and_required_changes" in error for error in errors),
|
|
)
|
|
self.assertTrue(any("P1-CRISIS-004" in error and "re_review" in error for error in errors))
|
|
self.assertTrue(any("youth_review_responses.safeguarding" in error for error in errors))
|
|
|
|
def test_completed_state_requires_reviewer_qualification_and_notes(self) -> None:
|
|
root = self._fixture()
|
|
self._complete(root, "approved", ["pass"] * 6)
|
|
manifest_path = root / checker.MANIFEST_REL
|
|
manifest = _read_json(manifest_path)
|
|
manifest["approval"]["reviewer_role_or_qualification"] = " "
|
|
manifest["approval"]["notes"] = None
|
|
_write_json(manifest_path, manifest)
|
|
|
|
errors = checker.validate_review_contract(repo_root=root)
|
|
self.assertTrue(any("reviewer_role_or_qualification" in error for error in errors))
|
|
self.assertTrue(any("approval.notes" in error for error in errors))
|
|
|
|
def test_conditional_or_fail_case_rejects_no_change_and_no_re_review_claims(self) -> None:
|
|
fixtures = (
|
|
("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)
|
|
case_set_path = root / checker.CASE_SET_REL
|
|
case_set = _read_json(case_set_path)
|
|
case_set["cases"][0]["reviewer_assessment"][
|
|
"findings_and_required_changes"
|
|
] = "수정 요구 없음"
|
|
case_set["cases"][0]["reviewer_assessment"]["re_review"] = "재검토 불필요"
|
|
_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("실제 수정 요구" in error for error in errors))
|
|
self.assertTrue(any("재검토 제출 조건" in error for error in errors))
|
|
|
|
def test_youth_review_response_keys_must_match_exactly_without_jsonschema(self) -> None:
|
|
root = self._fixture()
|
|
case_set_path = root / checker.CASE_SET_REL
|
|
case_set = _read_json(case_set_path)
|
|
del case_set["youth_review_responses"]["documentation"]
|
|
case_set["youth_review_responses"]["unexpected"] = None
|
|
_write_json(case_set_path, case_set)
|
|
|
|
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("youth_review_responses: 필드 불일치" 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["review_complete"])
|
|
self.assertFalse(summary["external_review_replaced"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|