"""shinsal.py — 지장간/신살/공망/지지 상호작용 검증. fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증. """ import json from pathlib import Path import pytest from app.calculator.core import calculate_saju from app.calculator.shinsal import ( get_hidden_stems, get_all_hidden_stems, analyze_branch_interactions, calculate_shinsal, calculate_gongmang, ) REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json" REF = json.loads(REF_PATH.read_text(encoding="utf-8")) def _camel_to_snake(name: str) -> str: out = [] for ch in name: if ch.isupper(): out.append("_" + ch.lower()) else: out.append(ch) return "".join(out) def _normalize(d): """TS camelCase → Python snake_case (deep). 값은 변경하지 않음.""" if isinstance(d, dict): return {_camel_to_snake(k): _normalize(v) for k, v in d.items()} if isinstance(d, list): return [_normalize(x) for x in d] return d def _canonical(item): """dict/list 비교용 — 안정 정렬을 위한 JSON 직렬화.""" return json.dumps(item, ensure_ascii=False, sort_keys=True) # ─── 1. 지장간 단순 테스트 ──────────────────────────────────────────── def test_hidden_stems_basic(): assert get_hidden_stems("子") == ["癸"] assert get_hidden_stems("丑") == ["己", "癸", "辛"] assert get_hidden_stems("寅") == ["甲", "丙", "戊"] assert get_hidden_stems("卯") == ["乙"] assert get_hidden_stems("辰") == ["戊", "乙", "癸"] assert get_hidden_stems("巳") == ["丙", "庚", "戊"] assert get_hidden_stems("午") == ["丁", "己"] assert get_hidden_stems("未") == ["己", "丁", "乙"] assert get_hidden_stems("申") == ["庚", "壬", "戊"] assert get_hidden_stems("酉") == ["辛"] assert get_hidden_stems("戌") == ["戊", "辛", "丁"] assert get_hidden_stems("亥") == ["壬", "甲"] # ─── 2. hiddenStems reference 매칭 ─────────────────────────────────── @pytest.mark.parametrize( "case", REF, ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", ) def test_hidden_stems_match_reference(case): inp = case["input"] expected_raw = case["expected"]["analysis"].get("hiddenStems") if expected_raw is None: pytest.skip("no hiddenStems in expected") expected = _normalize(expected_raw) saju = calculate_saju( inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"] ) actual = get_all_hidden_stems(saju) assert actual == expected, f"mismatch for {inp}\nactual={actual}\nexpected={expected}" # ─── 3. branchInteractions reference 매칭 ──────────────────────────── @pytest.mark.parametrize( "case", REF, ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", ) def test_branch_interactions_match_reference(case): inp = case["input"] expected_raw = case["expected"]["analysis"].get("branchInteractions") if expected_raw is None: pytest.skip("no branchInteractions") expected = _normalize(expected_raw) saju = calculate_saju( inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"] ) actual = analyze_branch_interactions(saju) # 순서가 다를 수 있으므로 정렬 후 비교 assert sorted(map(_canonical, actual)) == sorted(map(_canonical, expected)), ( f"mismatch for {inp}\nactual={actual}\nexpected={expected}" ) # ─── 4. shinsal reference 매칭 ─────────────────────────────────────── @pytest.mark.parametrize( "case", REF, ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", ) def test_shinsal_match_reference(case): inp = case["input"] expected_raw = case["expected"]["analysis"].get("shinsal") if expected_raw is None: pytest.skip("no shinsal") expected = _normalize(expected_raw) saju = calculate_saju( inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"] ) actual = calculate_shinsal(saju) assert sorted(map(_canonical, actual)) == sorted(map(_canonical, expected)), ( f"mismatch for {inp}\nactual={actual}\nexpected={expected}" ) # ─── 5. gongmang reference 매칭 ────────────────────────────────────── @pytest.mark.parametrize( "case", REF, ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", ) def test_gongmang_match_reference(case): inp = case["input"] expected_raw = case["expected"]["analysis"].get("gongmang") if expected_raw is None: pytest.skip("no gongmang") expected = _normalize(expected_raw) saju = calculate_saju( inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"] ) actual = calculate_gongmang(saju["day_stem"], saju["day"]["branch"]) # branches / branches_kr / description 전체 비교 assert actual.get("branches") == expected.get("branches"), ( f"branches mismatch for {inp}\nactual={actual}\nexpected={expected}" ) assert actual.get("branches_kr") == expected.get("branches_kr"), ( f"branches_kr mismatch for {inp}\nactual={actual}\nexpected={expected}" ) assert actual.get("description") == expected.get("description"), ( f"description mismatch for {inp}\nactual={actual}\nexpected={expected}" )