from __future__ import annotations """``normalize.py`` 회귀 테스트. 정본: ``docs/design/02-data-model.md`` §2(등록번호 파싱) §4(정규화 알고리즘). 집중 대상(임무 지시서): **dmf_key 안정성 · 국가 다중값 분해 · 성분명 표기 흔들림**. 등록번호 파서 회귀표(§2.5)는 이 모듈의 정본이므로 그대로 파라미터화한다. """ import pytest from dmf_crawler.models import COL_APPLICANT, COL_COUNTRY, COL_INGREDIENT from dmf_crawler.models import COL_MANUFACTURER, COL_PERMIT_DATE, COL_PERMIT_NO from dmf_crawler.models import COL_PLACE, RawRecord normalize = pytest.importorskip("dmf_crawler.normalize") def _raw( permit_no: str = "20200101-1-A-1-1", *, ingredient: str = "테스트성분", applicant: str = "테스트제약", manufacturer: str = "Test Manufacturer", place: str = "1 Test Rd, Testville", country: str = "미국", permit_date: str = "2020-01-01", ) -> RawRecord: return RawRecord(fields={ COL_PERMIT_NO: permit_no, COL_INGREDIENT: ingredient, COL_APPLICANT: applicant, COL_MANUFACTURER: manufacturer, COL_PLACE: place, COL_COUNTRY: country, COL_PERMIT_DATE: permit_date, }) # ========================================================================== # 1. 등록번호 파서 — docs/design/02-data-model.md §2.5 회귀표 그대로 # ========================================================================== # (입력, fmt, accept_date, ingr_no, group, serial, sub, grant, ingr_group_key) _PERMIT_REGRESSION_CASES = [ pytest.param( "20110531-71-B-317-05", "standard", "2011-05-31", 71, "B", 317, 5, None, "71-B", id="standard-basic", ), pytest.param( "20110531-71-B-317-05(1)", "standard", "2011-05-31", 71, "B", 317, 5, 1, "71-B", id="standard-grant-derived", ), pytest.param( "20260901-209-J-2270", "standard", "2026-09-01", 209, "J", 2270, None, None, "209-J", id="standard-j-group-no-sub", ), pytest.param( "20121228-168-I-169-04", "standard", "2012-12-28", 168, "I", 169, 4, None, "168-I", id="standard-i-group", ), pytest.param( "수6580-16-ND(20)", "new_substance", None, None, None, None, None, 20, None, id="new-substance", ), pytest.param( "20260231-1-A-1-1", "standard", None, 1, "A", 1, 1, None, "1-A", id="standard-invalid-calendar-date", ), pytest.param( "20260901-209-Z-1", "standard", "2026-09-01", 209, "Z", 1, None, None, "209-Z", id="standard-unknown-group", ), pytest.param( "이상한값", "unknown", None, None, None, None, None, None, None, id="unknown-garbage", ), pytest.param( "", "unknown", None, None, None, None, None, None, None, id="unknown-empty", ), ] @pytest.mark.parametrize( "raw,fmt,accept_date,ingr_no,group,serial,sub,grant,ingr_group_key", _PERMIT_REGRESSION_CASES, ) def test_parse_permit_no_regression_table( raw, fmt, accept_date, ingr_no, group, serial, sub, grant, ingr_group_key ): """§2.5 회귀표의 각 행을 그대로 검증한다. 어떤 입력에서도 예외를 던지지 않는다.""" parts = normalize.parse_permit_no(raw) assert parts.fmt == fmt assert parts.accept_date == accept_date assert parts.ingr_no == ingr_no assert parts.group == group assert parts.serial == serial assert parts.sub == sub assert parts.grant == grant assert parts.ingr_group_key == ingr_group_key assert parts.raw == raw def test_parse_permit_no_fullwidth_and_space_noise_normalizes_identically(): """전각 하이픈·전각(이데오그래픽) 공백이 섞여도 §2.5 의 표준 케이스와 같은 결과가 나온다.""" noisy = "20121228-168-I-169-04 " # 전각 하이픈(-) + 말미 전각 공백( ) clean = normalize.parse_permit_no("20121228-168-I-169-04") noisy_parts = normalize.parse_permit_no(noisy) assert noisy_parts.fmt == clean.fmt == "standard" assert noisy_parts.normalized == clean.normalized == "20121228-168-I-169-04" assert noisy_parts.accept_date == "2012-12-28" assert noisy_parts.ingr_no == 168 assert noisy_parts.group == "I" assert noisy_parts.serial == 169 assert noisy_parts.sub == 4 assert noisy_parts.ingr_group_key == "168-I" def test_parse_permit_no_unknown_group_keeps_parsing_but_drops_table_fields(): """GROUP_TABLE 에 없는 군은 파싱은 성공시키되 group_table·group_effective_from 을 비운다.""" parts = normalize.parse_permit_no("20260901-209-Z-1") assert parts.fmt == "standard" assert parts.group == "Z" assert parts.group_table is None assert parts.group_effective_from is None def test_parse_permit_no_grant_sets_base_permit_no_and_flag(): """허여서 파생 등록번호는 괄호를 뗀 기준 등록번호와 파생 플래그를 함께 갖는다.""" parts = normalize.parse_permit_no("20110531-71-B-317-05(1)") assert parts.is_grant_derived is True assert parts.base_permit_no == "20110531-71-B-317-05" not_derived = normalize.parse_permit_no("20110531-71-B-317-05") assert not_derived.is_grant_derived is False assert not_derived.grant is None def test_parse_permit_no_never_raises_on_garbage(): """어떤 쓰레기 입력에도 예외 없이 fmt='unknown' 을 돌려준다(결정 D7).""" for garbage in (None, "", " ", "####", "20200101", "()()()", "A" * 500): parts = normalize.parse_permit_no(garbage) assert parts.fmt in ("unknown", "standard", "new_substance") # ========================================================================== # 2. dmf_key 안정성 # ========================================================================== def test_make_dmf_key_normal_and_duplicate_suffix(): """정상 건은 등록번호 그대로, 중복 건은 #2·#3 결정론적 접미사를 받는다.""" assert normalize.make_dmf_key("20121228-168-I-169-04", 0) == "20121228-168-I-169-04" assert normalize.make_dmf_key("20121228-168-I-169-04", 1) == "20121228-168-I-169-04#2" assert normalize.make_dmf_key("20121228-168-I-169-04", 2) == "20121228-168-I-169-04#3" def test_make_dmf_key_synthetic_for_missing_permit_no(): """등록번호가 없으면 'SYN-' + sha1 앞 12자의 합성키를 만든다.""" key = normalize.make_dmf_key("", 0, fallback_seed="ingr|applicant|mnf|2020-01-01") assert key.startswith("SYN-") assert len(key) == len("SYN-") + 12 def test_make_dmf_key_synthetic_is_deterministic(): """같은 시드는 항상 같은 합성키를 만든다 — diff 가 매일 같은 키로 짝지을 수 있어야 한다.""" seed = "ingr|applicant|mnf|2020-01-01" assert normalize.make_dmf_key("", 0, fallback_seed=seed) == normalize.make_dmf_key( "", 0, fallback_seed=seed ) def test_make_dmf_key_synthetic_differs_by_seed(): """시드가 다르면 합성키도 달라야 한다(서로 다른 레코드가 같은 키로 충돌하면 안 된다).""" a = normalize.make_dmf_key("", 0, fallback_seed="a|b|c|2020-01-01") b = normalize.make_dmf_key("", 0, fallback_seed="x|y|z|2020-01-01") assert a != b def test_normalize_all_dmf_key_deterministic_across_runs(): """같은 원본 입력을 두 번 정규화해도 dmf_key·해시가 완전히 같다(순수 함수 재현성).""" raws = [ _raw("20121228-168-I-169-04", ingredient="포르모테롤푸마르산염수화물"), _raw("20110531-71-B-317-05", ingredient="아토르바스타틴칼슘삼수화물"), _raw("수6580-16-ND(20)", ingredient="프레가발린"), ] records_1, stats_1 = normalize.normalize_all(list(raws)) records_2, stats_2 = normalize.normalize_all(list(raws)) keys_1 = [r.dmf_key for r in records_1] keys_2 = [r.dmf_key for r in records_2] assert keys_1 == keys_2 assert len(set(keys_1)) == len(keys_1), "한 스냅샷 안에서 dmf_key 는 유일해야 한다" hashes_1 = {r.dmf_key: (r.content_hash, r.identity_hash) for r in records_1} hashes_2 = {r.dmf_key: (r.content_hash, r.identity_hash) for r in records_2} assert hashes_1 == hashes_2 assert stats_1.total_out == stats_2.total_out == 3 def test_normalize_all_duplicate_permit_no_gets_deterministic_suffix_regardless_of_input_order(): """중복 등록번호는 정렬 튜플로 접미사가 결정되며, 입력 순서를 바꿔도 같은 결과가 나온다.""" dup_permit = "20110531-71-B-317-05" zeta = _raw(dup_permit, manufacturer="Zeta Labs", applicant="Zeta Pharma") alpha = _raw(dup_permit, manufacturer="Alpha Labs", applicant="Alpha Pharma") order_a, stats_a = normalize.normalize_all([zeta, alpha]) order_b, stats_b = normalize.normalize_all([alpha, zeta]) by_manufacturer_a = {r.manufacturer: r.dmf_key for r in order_a} by_manufacturer_b = {r.manufacturer: r.dmf_key for r in order_b} assert by_manufacturer_a == by_manufacturer_b, "입력 순서와 무관하게 같은 레코드가 같은 접미사를 받아야 한다" assert stats_a.duplicate_permit_no == stats_b.duplicate_permit_no == 1 assert stats_a.duplicate_groups == stats_b.duplicate_groups == 1 # 두 dmf_key 는 서로 달라야 하고(유일성), 하나는 접미사가 없어야 한다. keys = set(by_manufacturer_a.values()) assert len(keys) == 2 assert dup_permit in keys assert f"{dup_permit}#2" in keys # ========================================================================== # 3. 국가 다중값 분해 # ========================================================================== def test_split_countries_order_independent(): """콤마로 나열된 국가는 정렬돼 순서 흔들림이 오탐을 만들지 않는다(요구 R2.3).""" a = normalize.split_countries("이탈리아,스위스") b = normalize.split_countries("스위스,이탈리아") assert a == b assert set(a) == {"이탈리아", "스위스"} assert len(a) == 2 def test_split_countries_dedup(): """중복 국가명은 한 번만 남는다.""" result = normalize.split_countries("이탈리아,이탈리아,스위스") assert set(result) == {"이탈리아", "스위스"} assert len(result) == 2 def test_split_countries_alias_resolution(): """영문·별칭 표기가 표준 한글명으로 접힌다.""" result = normalize.split_countries("USA,Korea") assert set(result) == {"미국", "한국"} def test_split_countries_alternate_delimiters(): """콤마 외 구분자(슬래시·가운뎃점·세미콜론·파이프)도 분해된다.""" for text in ("이탈리아/스위스", "이탈리아·스위스", "이탈리아;스위스", "이탈리아|스위스"): result = normalize.split_countries(text) assert set(result) == {"이탈리아", "스위스"}, f"구분자 처리 실패: {text!r}" def test_split_countries_empty_and_blank(): """빈 값·공백만 있는 값은 빈 튜플을 돌려준다(널 방어).""" assert normalize.split_countries("") == () assert normalize.split_countries(None) == () assert normalize.split_countries(" ") == () def test_normalize_all_country_order_does_not_change_dmf_record(): """API 응답의 국가 순서가 오락가락해도 정규화 결과(countries 튜플)는 항상 같다.""" raw_a = _raw("20200101-1-A-1-1", country="이탈리아,스위스") raw_b = _raw("20200101-1-A-1-1", country="스위스,이탈리아") rec_a = normalize.normalize_one(raw_a) rec_b = normalize.normalize_one(raw_b) assert rec_a.countries == rec_b.countries assert rec_a.content_hash == rec_b.content_hash # ========================================================================== # 4. 성분명 표기 흔들림 # ========================================================================== def test_matching_key_absorbs_original_typo_spacing(): """원본 오탈자 공백(CORTICOSTER OIDO)이 정정본과 같은 매칭키가 된다(§4.2).""" typo = normalize.matching_key("CORTICOSTER OIDO") fixed = normalize.matching_key("CORTICOSTEROIDO") assert typo == fixed == "corticosteroido" def test_matching_key_case_and_punctuation_insensitive(): """대소문자·구두점 차이는 매칭키에서 사라진다.""" a = normalize.matching_key("S.R.L.") b = normalize.matching_key("s.r.l.") assert a == b def test_normalize_ingredient_strips_hydrate_and_salt_suffix_for_base(): """ingredient_base 는 수화물·염 접미를 반복 제거한 기본명이다. 동일성 판정에는 쓰지 않는다.""" display, key, base, micronized = normalize.normalize_ingredient("포르모테롤푸마르산염수화물") assert display == "포르모테롤푸마르산염수화물" assert key == "포르모테롤푸마르산염수화물" assert base == "포르모테롤" assert micronized is False def test_normalize_ingredient_micronized_prefix(): """미분화 접두는 감지되고 base 산출 시 제거된다.""" display, key, base, micronized = normalize.normalize_ingredient("미분화포르모테롤푸마르산염수화물") assert micronized is True assert base == "포르모테롤" assert display.startswith("미분화") def test_normalize_ingredient_whitespace_variance_same_key_different_display(): """성분명에 불필요한 공백이 섞여도 매칭키는 같다. 다만 표시값은 다를 수 있다(COSMETIC 원천).""" display_a, key_a, _, _ = normalize.normalize_ingredient("포르모테롤 푸마르산염수화물") display_b, key_b, _, _ = normalize.normalize_ingredient("포르모테롤푸마르산염수화물") assert key_a == key_b assert display_a != display_b def test_normalize_one_ingredient_notation_variance_only_changes_display(): """레코드 전체를 정규화해도 성분명 표기 흔들림은 ingredient_key 를 바꾸지 않는다.""" rec_spaced = normalize.normalize_one(_raw(ingredient="포르모테롤 푸마르산염수화물")) rec_tight = normalize.normalize_one(_raw(ingredient="포르모테롤푸마르산염수화물")) assert rec_spaced.ingredient_key == rec_tight.ingredient_key assert rec_spaced.ingredient_name != rec_tight.ingredient_name # 표시값이 다르므로 content_hash 는 달라야 하지만, identity_hash(매칭키 기준)는 같아야 한다. assert rec_spaced.content_hash != rec_tight.content_hash assert rec_spaced.identity_hash == rec_tight.identity_hash # ========================================================================== # 5. 조직명(신청인·제조소) 정규화 # ========================================================================== def test_normalize_org_key_removes_legal_form_variants(): """'(주)대웅제약'과 '주식회사 대웅제약'은 같은 매칭키가 된다.""" key_a = normalize.normalize_org_key(normalize.normalize_org_display("(주)대웅제약")) key_b = normalize.normalize_org_key(normalize.normalize_org_display("주식회사 대웅제약")) assert key_a == key_b == "대웅제약" def test_normalize_org_display_folds_hanja_circle_marks(): """㈜ 같은 원문자 법인격 기호는 표시 단계에서 (주) 로 통일된다.""" display = normalize.normalize_org_display("㈜하이플") assert display == "(주)하이플" # ========================================================================== # 6. NormalizeStats — 필수 필드 널 비율·중복 통계 # ========================================================================== def test_normalize_all_null_counts_tracks_missing_required_fields(): """필수 필드(성분명)가 빈 값이면 null_counts 에 반영된다(무결성 게이트 4의 입력).""" raws = [ _raw("20200101-1-A-1-1", ingredient=""), _raw("20200102-1-A-1-2", ingredient="정상성분"), ] _, stats = normalize.normalize_all(raws) assert stats.total_in == 2 assert stats.null_counts.get("ingredient_name", 0) >= 1 def test_normalize_all_clean_input_has_no_rejects(): """정상 입력만 주면 rejected 는 비어 있어야 한다.""" raws = [_raw("20200101-1-A-1-1"), _raw("20200102-1-A-1-2")] records, stats = normalize.normalize_all(raws) assert stats.rejected == () assert stats.total_out == len(records) == 2 def test_normalize_all_synthetic_key_stats_when_permit_no_missing(): """등록번호가 빈 레코드는 합성키를 받고 synthetic_key_count 에 집계된다.""" raws = [_raw("")] records, stats = normalize.normalize_all(raws) assert stats.synthetic_key_count >= 1 assert any(r.is_synthetic_key for r in records) assert any(r.dmf_key.startswith("SYN-") for r in records) # ========================================================================== # 7. compute_content_hash · compute_identity_hash # ========================================================================== def test_compute_hashes_are_16_hex_chars_and_deterministic(): rec = normalize.normalize_one(_raw("20121228-168-I-169-04")) h1 = normalize.compute_content_hash(rec) h2 = normalize.compute_content_hash(rec) i1 = normalize.compute_identity_hash(rec) assert h1 == h2 assert len(h1) == 16 assert all(c in "0123456789abcdef" for c in h1) assert len(i1) == 16 def test_compute_content_hash_changes_when_compare_field_changes(): """COMPARE_FIELDS 중 하나라도 바뀌면 content_hash 가 바뀐다.""" rec_a = normalize.normalize_one(_raw("20200101-1-A-1-1", applicant="Applicant A")) rec_b = normalize.normalize_one(_raw("20200101-1-A-1-1", applicant="Applicant B")) assert normalize.compute_content_hash(rec_a) != normalize.compute_content_hash(rec_b)