- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
390 lines
18 KiB
Python
390 lines
18 KiB
Python
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)
|