"""윤문 전후에 보호 구간이 그대로인지 보는 검사기. 정상 윤문은 통과하고, 수치·코드·인용·URL 을 건드린 편집은 걸린다. 유보 표현이 줄어든 것은 걸러 내되 판정하지 않는다 — 판단은 근거를 읽는 검토가 한다. """ import importlib.util import os import unittest ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) F = os.path.join(ROOT, "scripts", "tests", "fixtures", "preservation") _spec = importlib.util.spec_from_file_location( "check_preservation", os.path.join(ROOT, "scripts", "check-preservation.py")) cp = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(cp) def _read(name): return open(os.path.join(F, name), encoding="utf-8").read() class PreservationTest(unittest.TestCase): def setUp(self): self.before = _read("before.md") def test_an_ordinary_rewrite_passes(self): res = cp.compare(self.before, _read("after-ok.md")) self.assertEqual([], res["findings"]) self.assertEqual([], res["hedgesDropped"]) def test_a_number_changed_in_prose_is_caught(self): res = cp.compare(self.before, _read("after-tampered.md")) values = {f["value"] for f in res["findings"] if f["kind"] == "수치"} self.assertIn("14ms", values) self.assertIn("4ms", values) def test_a_number_changed_inside_a_code_block_is_caught(self): res = cp.compare(self.before, _read("after-tampered.md")) blocks = {f["value"] for f in res["findings"] if f["kind"] == "코드블록"} self.assertTrue(any("exit=1" in b for b in blocks)) self.assertTrue(any("exit=0" in b for b in blocks)) def test_a_changed_direct_quotation_is_caught(self): res = cp.compare(self.before, _read("after-tampered.md")) kinds = {f["kind"] for f in res["findings"]} self.assertIn("직접인용", kinds) def test_a_changed_url_is_caught(self): res = cp.compare(self.before, _read("after-tampered.md")) kinds = {f["kind"] for f in res["findings"]} self.assertIn("URL", kinds) def test_dropped_hedges_are_surfaced_without_a_verdict(self): """유보가 줄면 낸다. 옳은지 그른지는 말하지 않는다.""" res = cp.compare(self.before, _read("after-tampered.md")) dropped = {h["word"] for h in res["hedgesDropped"]} self.assertIn("확인하지 못했다", dropped) self.assertIn("로컬", dropped) self.assertEqual(0, res["hedgeTotalAfter"]) def test_adding_a_hedge_is_not_reported(self): """유보를 더하는 것은 이 규범에서 안전한 쪽이다.""" after = self.before.replace("14ms 였다", "14ms 였다. 다만 한 번만 쟀다") res = cp.compare(self.before, after) self.assertEqual([], res["hedgesDropped"]) def test_a_whole_section_removed_is_surfaced(self): """절을 통째로 지우면 안에 보호 구간이 없어도 보여야 한다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다. 낱말 계수만 보면 아무것도 안 움직여서 경고가 비고, 「경고가 비면 자동 통과」로 그대로 지나간다. """ before = ("# 제목\n\n요약이다.\n\n## 결론\n\n값은 14ms 였다.\n\n" "## 확인하지 못한 것\n\n다른 환경에서는 세 보지 않았다.\n") after = "# 제목\n\n요약이다.\n\n## 결론\n\n값은 14ms 였다.\n" res = cp.compare(before, after) self.assertEqual([], res["errors"], "절 삭제는 error 가 아니다") gone = [w["value"] for w in res["warnings"] if w["kind"] == "절"] self.assertIn("## 확인하지 못한 것", gone) def test_a_section_that_stays_is_not_reported(self): """대조군. 절이 그대로면 아무것도 안 나온다.""" text = "# 제목\n\n요약이다.\n\n## 결론\n\n값은 14ms 였다.\n" self.assertEqual([], cp.compare(text, text)["warnings"]) def test_limitation_hedges_are_counted(self): """「안 했다」로 한계를 밝히는 말도 유보다. 목록이 추정·가능성 쪽에만 몰려 있으면 한계를 적은 문장을 지워도 계수가 안 움직인다. """ for word in ("세 보지 않았다", "돌려 보지 않았다", "재 보지 않았다", "확인 안 했다"): with self.subTest(word=word): before = f"값은 14ms 였다. 다른 환경에서는 {word}." after = "값은 14ms 였다." dropped = {h["word"] for h in cp.compare(before, after)["hedgesDropped"]} self.assertIn(word, dropped) def test_a_missing_file_is_not_reported_as_clean(self): import subprocess p = subprocess.run( ["python3", os.path.join(ROOT, "scripts", "check-preservation.py"), os.path.join(F, "before.md"), os.path.join(F, "nope.md")], cwd=ROOT, capture_output=True, text=True) self.assertEqual(2, p.returncode) if __name__ == "__main__": unittest.main() class KoreanNumeralTest(unittest.TestCase): """한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다. 이 배치에서 실제로 한 번 놓쳤고 사람이 잡았다. """ def test_a_changed_korean_numeral_is_an_error(self): for before, after in (("증거 다섯 개", "증거 여섯 개"), ("문장 세 줄", "문장 네 줄"), ("열두 건", "열세 건")): with self.subTest(before=before): self.assertTrue(cp.compare(before, after)["errors"]) def test_counting_words_that_are_not_numbers_are_left_alone(self): """대조군. 「하나뿐」·「둘 다」는 수가 아니다. 세면 정상을 막는다.""" for text in ("하나뿐이다", "둘 다 맞다", "한편으로는 그렇다"): with self.subTest(text=text): self.assertEqual([], cp.compare(text, text)["errors"]) class UnsourcedVoiceTest(unittest.TestCase): """자료에 없는 1인칭. 보호 구간 비교로는 원리적으로 안 보이던 자리다.""" BEFORE = "피드 아이템을 엔티티로 조회한 뒤 메모리에서 DTO로 옮기는 코드다." def test_added_first_person_experience_is_surfaced_as_a_warning(self): after = ("처음에는 조인 한 번으로 가져올 거라고 믿었다. " "팀에서는 다른 의견이 많았지만 나는 조회 방식을 먼저 보자고 했다.\n\n" + self.BEFORE) res = cp.compare(self.BEFORE, after) self.assertEqual([], res["errors"], "판정이 아니라 라우팅이다") marks = {w["value"] for w in res["warnings"] if w["kind"] == "1인칭"} self.assertTrue({"처음에는", "믿었다", "팀에서는"} <= marks, marks) def test_the_warning_points_at_the_sentence(self): after = "처음에는 그렇게 믿었다.\n\n" + self.BEFORE w = next(x for x in cp.compare(self.BEFORE, after)["warnings"] if x["kind"] == "1인칭") self.assertIn("처음에는", w["sentence"]) def test_removing_first_person_is_not_reported(self): """지우는 것은 이 규범에서 안전한 쪽이다.""" after = "처음에는 그렇게 믿었다. " + self.BEFORE self.assertEqual([], [w for w in cp.compare(after, self.BEFORE)["warnings"] if w["kind"] == "1인칭"]) def test_an_ordinary_rewrite_raises_no_voice_warning(self): """대조군.""" self.assertEqual([], [w for w in cp.compare(self.BEFORE, self.BEFORE)["warnings"] if w["kind"] == "1인칭"])