From 234ccfe857ac354cad13364967ad8f568c47eaeb Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 19:54:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(saju-lab):=20shinsal.py=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=80=EC=9E=A5=EA=B0=84/=EC=8B=A0=EC=82=B4/=EA=B3=B5?= =?UTF-8?q?=EB=A7=9D/=EC=A7=80=EC=A7=80=20=EC=83=81=ED=98=B8=EC=9E=91?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- saju-lab/app/calculator/shinsal.py | 482 +++++++++++++++++++++++++++++ saju-lab/tests/test_shinsal.py | 156 ++++++++++ 2 files changed, 638 insertions(+) create mode 100644 saju-lab/app/calculator/shinsal.py create mode 100644 saju-lab/tests/test_shinsal.py diff --git a/saju-lab/app/calculator/shinsal.py b/saju-lab/app/calculator/shinsal.py new file mode 100644 index 0000000..b3bac83 --- /dev/null +++ b/saju-lab/app/calculator/shinsal.py @@ -0,0 +1,482 @@ +"""신살 + 지장간 + 공망 + 지지 상호작용. + +saju-web/lib/saju-calculator.ts 의 다음 함수와 1:1 매핑: +- getHiddenStems / getAllHiddenStems (지장간) +- analyzeBranchInteractions (육합/삼합/방합/충/형/자형/파/해) +- calculateShinsal (역마/도화/화개/천을귀인/문창귀인/천덕귀인) +- calculateGongmang (공망) + +입력 saju dict는 core.calculate_saju 의 snake_case 출력 (year/month/day/hour pillar +각각 stem, branch, stem_kr, branch_kr, element, ten_god, fortune. day_stem 별도). +출력 키는 snake_case (branch_kr, branches_kr, result_element 등). 단, `pillar` 값은 +TS와 동일하게 한글(`년주`/`월주`/`일주`/`시주`)을 사용. +""" +from __future__ import annotations + +from typing import Optional + +from .constants import ( + HEAVENLY_STEMS, + HEAVENLY_STEMS_KR, + EARTHLY_BRANCHES, + EARTHLY_BRANCHES_KR, + FIVE_ELEMENTS, + HIDDEN_STEMS, +) + + +# ─── 지장간 ──────────────────────────────────────────────────────── +def get_hidden_stems(branch: str) -> list[str]: + """지지의 지장간(숨은 천간) — [본기, 중기, 여기].""" + return list(HIDDEN_STEMS.get(branch, [])) + + +def get_all_hidden_stems(saju: dict) -> list[dict]: + """4주 전체의 지장간 정보 반환. + + 출력 구조: + [{ + "pillar": "년주", + "branch": "午", + "branch_kr": "오", + "stems": [ + {"stem": "丁", "stem_kr": "정", "element": "火", "role": "정기(본기)"}, + {"stem": "己", "stem_kr": "기", "element": "土", "role": "중기"}, + ], + }, ...] + """ + pillars: list[dict] = [ + {"pillar": "년주", "branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"]}, + {"pillar": "월주", "branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"]}, + {"pillar": "일주", "branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"]}, + ] + if saju.get("hour") is not None: + pillars.append({ + "pillar": "시주", + "branch": saju["hour"]["branch"], + "branch_kr": saju["hour"]["branch_kr"], + }) + + result: list[dict] = [] + for p in pillars: + hidden = get_hidden_stems(p["branch"]) + stems = [] + for idx, stem in enumerate(hidden): + stem_index = HEAVENLY_STEMS.index(stem) + if idx == 0: + role = "정기(본기)" + elif idx == 1: + role = "중기" + else: + role = "여기" + stems.append({ + "stem": stem, + "stem_kr": HEAVENLY_STEMS_KR[stem_index], + "element": FIVE_ELEMENTS[stem], + "role": role, + }) + result.append({ + "pillar": p["pillar"], + "branch": p["branch"], + "branch_kr": p["branch_kr"], + "stems": stems, + }) + return result + + +# ─── 지지 상호작용 ────────────────────────────────────────────────── +# 육합 (六合): 子丑(土), 寅亥(木), 卯戌(火), 辰酉(金), 巳申(水), 午未(火) +_YUKAP_PAIRS: list[tuple[str, str, str]] = [ + ("子", "丑", "土"), ("寅", "亥", "木"), ("卯", "戌", "火"), + ("辰", "酉", "金"), ("巳", "申", "水"), ("午", "未", "火"), +] + +# 삼합 (三合) +_SAMHAP_GROUPS: list[tuple[str, str, str, str]] = [ + ("申", "子", "辰", "水"), ("亥", "卯", "未", "木"), + ("寅", "午", "戌", "火"), ("巳", "酉", "丑", "金"), +] + +# 방합 (方合) +_BANGHAP_GROUPS: list[tuple[str, str, str, str]] = [ + ("寅", "卯", "辰", "木"), ("巳", "午", "未", "火"), + ("申", "酉", "戌", "金"), ("亥", "子", "丑", "水"), +] + +# 충 (沖) +_CHUNG_PAIRS: list[tuple[str, str]] = [ + ("子", "午"), ("丑", "未"), ("寅", "申"), + ("卯", "酉"), ("辰", "戌"), ("巳", "亥"), +] + +# 형 (刑) +_HYUNG_GROUPS: list[dict] = [ + {"branches": ["寅", "巳", "申"], "name": "무은지형(無恩之刑)"}, + {"branches": ["丑", "戌", "未"], "name": "지세지형(恃勢之刑)"}, + {"branches": ["子", "卯"], "name": "무례지형(無禮之刑)"}, +] +_JAHYUNG_BRANCHES = ["辰", "午", "酉", "亥"] + +# 파 (破) +_PA_PAIRS: list[tuple[str, str]] = [ + ("子", "酉"), ("丑", "辰"), ("寅", "亥"), + ("卯", "午"), ("巳", "申"), ("未", "戌"), +] + +# 해 (害) +_HAE_PAIRS: list[tuple[str, str]] = [ + ("子", "未"), ("丑", "午"), ("寅", "巳"), + ("卯", "辰"), ("申", "亥"), ("酉", "戌"), +] + +_ELEMENT_NAMES_KR: dict[str, str] = { + "木": "목", "火": "화", "土": "토", "金": "금", "水": "수", +} + + +def _collect_pillar_branches(saju: dict) -> list[dict]: + """4주 지지 리스트 — TS analyzeBranchInteractions 내부 변수와 동일 순서.""" + out: list[dict] = [ + {"branch": saju["year"]["branch"], "pillar": "년주", "branch_kr": saju["year"]["branch_kr"]}, + {"branch": saju["month"]["branch"], "pillar": "월주", "branch_kr": saju["month"]["branch_kr"]}, + {"branch": saju["day"]["branch"], "pillar": "일주", "branch_kr": saju["day"]["branch_kr"]}, + ] + if saju.get("hour") is not None: + out.append({ + "branch": saju["hour"]["branch"], + "pillar": "시주", + "branch_kr": saju["hour"]["branch_kr"], + }) + return out + + +def analyze_branch_interactions(saju: dict) -> list[dict]: + """지지 상호작용 분석 (육합/삼합/반삼합/방합/충/형/자형/파/해). + + TS analyzeBranchInteractions 와 1:1 동일. branches는 첫 번째 발견 인덱스만 본다 + (중복 지지가 있을 때 TS의 indexOf 동작과 동일). + """ + interactions: list[dict] = [] + pillar_branches = _collect_pillar_branches(saju) + branches = [pb["branch"] for pb in pillar_branches] + + # 육합 (六合) + for a, b, elem in _YUKAP_PAIRS: + if a in branches and b in branches: + ia = branches.index(a) + ib = branches.index(b) + interactions.append({ + "type": "육합(六合)", + "branches": [a, b], + "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], + "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], + "description": ( + f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 육합 " + f"→ {_ELEMENT_NAMES_KR[elem]}({elem}) 기운 생성. 조화와 화합의 관계." + ), + "result_element": elem, + }) + + # 삼합 (三合) / 반삼합 (半三合) + for a, b, c, elem in _SAMHAP_GROUPS: + found = [x for x in (a, b, c) if x in branches] + if len(found) >= 2: + found_pillars = [pillar_branches[branches.index(x)] for x in found] + is_complete = len(found) == 3 + type_kr = "삼합(三合)" if is_complete else "반삼합(半三合)" + kr_str = "".join(p["branch_kr"] for p in found_pillars) + samhap_label = "삼합" if is_complete else "반삼합" + tail = "강력한 합의 기운." if is_complete else "삼합의 기운이 부분적으로 작용." + interactions.append({ + "type": type_kr, + "branches": found, + "branches_kr": [p["branch_kr"] for p in found_pillars], + "pillars": [p["pillar"] for p in found_pillars], + "description": ( + f"{kr_str} {samhap_label} → {_ELEMENT_NAMES_KR[elem]}({elem})국. {tail}" + ), + "result_element": elem, + }) + + # 방합 (方合) + for a, b, c, elem in _BANGHAP_GROUPS: + found = [x for x in (a, b, c) if x in branches] + if len(found) == 3: + found_pillars = [pillar_branches[branches.index(x)] for x in found] + kr_str = "".join(p["branch_kr"] for p in found_pillars) + interactions.append({ + "type": "방합(方合)", + "branches": found, + "branches_kr": [p["branch_kr"] for p in found_pillars], + "pillars": [p["pillar"] for p in found_pillars], + "description": ( + f"{kr_str} 방합 → {_ELEMENT_NAMES_KR[elem]}({elem}) 방국. 매우 강한 오행 기운." + ), + "result_element": elem, + }) + + # 충 (沖) + for a, b in _CHUNG_PAIRS: + if a in branches and b in branches: + ia = branches.index(a) + ib = branches.index(b) + interactions.append({ + "type": "충(沖)", + "branches": [a, b], + "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], + "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], + "description": ( + f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 충 " + f"→ 변동, 갈등, 변화의 에너지. " + f"{pillar_branches[ia]['pillar']}와 {pillar_branches[ib]['pillar']} 사이의 긴장 관계." + ), + }) + + # 형 (刑) + for group in _HYUNG_GROUPS: + found = [x for x in group["branches"] if x in branches] + if len(found) >= 2: + found_pillars = [pillar_branches[branches.index(x)] for x in found] + kr_str = "".join(p["branch_kr"] for p in found_pillars) + interactions.append({ + "type": "형(刑)", + "branches": found, + "branches_kr": [p["branch_kr"] for p in found_pillars], + "pillars": [p["pillar"] for p in found_pillars], + "description": f"{kr_str} {group['name']} → 시련과 갈등의 기운. 주의가 필요한 관계.", + }) + + # 자형 (自刑) + for jb in _JAHYUNG_BRANCHES: + count = branches.count(jb) + if count >= 2: + br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(jb)] + interactions.append({ + "type": "자형(自刑)", + "branches": [jb, jb], + "branches_kr": [br_kr, br_kr], + "pillars": [p["pillar"] for p in pillar_branches if p["branch"] == jb], + "description": f"{br_kr}{br_kr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.", + }) + + # 파 (破) + for a, b in _PA_PAIRS: + if a in branches and b in branches: + ia = branches.index(a) + ib = branches.index(b) + interactions.append({ + "type": "파(破)", + "branches": [a, b], + "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], + "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], + "description": ( + f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 파 " + f"→ 관계의 균열, 계획의 차질 가능성." + ), + }) + + # 해 (害) + for a, b in _HAE_PAIRS: + if a in branches and b in branches: + ia = branches.index(a) + ib = branches.index(b) + interactions.append({ + "type": "해(害)", + "branches": [a, b], + "branches_kr": [pillar_branches[ia]["branch_kr"], pillar_branches[ib]["branch_kr"]], + "pillars": [pillar_branches[ia]["pillar"], pillar_branches[ib]["pillar"]], + "description": ( + f"{pillar_branches[ia]['branch_kr']}{pillar_branches[ib]['branch_kr']} 해 " + f"→ 은근한 방해, 원망의 기운." + ), + }) + + return interactions + + +# ─── 신살 ────────────────────────────────────────────────────────── +# 일지 삼합국 기준 신살 매핑 +_SAMHAP_GROUP_MAP: dict[str, str] = { + "申": "申子辰", "子": "申子辰", "辰": "申子辰", + "寅": "寅午戌", "午": "寅午戌", "戌": "寅午戌", + "巳": "巳酉丑", "酉": "巳酉丑", "丑": "巳酉丑", + "亥": "亥卯未", "卯": "亥卯未", "未": "亥卯未", +} +_YEOKMA_MAP: dict[str, str] = { + "申子辰": "寅", "寅午戌": "申", "巳酉丑": "亥", "亥卯未": "巳", +} +_DOHWA_MAP: dict[str, str] = { + "申子辰": "酉", "寅午戌": "卯", "巳酉丑": "午", "亥卯未": "子", +} +_HWAGAE_MAP: dict[str, str] = { + "申子辰": "辰", "寅午戌": "戌", "巳酉丑": "丑", "亥卯未": "未", +} + +# 천을귀인 (天乙貴人) — 일간 기준 +_CHEONUL_MAP: dict[str, list[str]] = { + "甲": ["丑", "未"], "乙": ["子", "申"], "丙": ["亥", "酉"], "丁": ["亥", "酉"], + "戊": ["丑", "未"], "己": ["子", "申"], "庚": ["丑", "未"], "辛": ["寅", "午"], + "壬": ["卯", "巳"], "癸": ["卯", "巳"], +} + +# 문창귀인 (文昌貴人) — 일간 기준 +_MUNCHANG_MAP: dict[str, str] = { + "甲": "巳", "乙": "午", "丙": "申", "丁": "酉", + "戊": "申", "己": "酉", "庚": "亥", "辛": "子", + "壬": "寅", "癸": "卯", +} + +# 천덕귀인 (天德貴人) — 월지 기준 천간 +_CHEONDUK_MAP: dict[str, str] = { + "寅": "丁", "卯": "申", "辰": "壬", "巳": "辛", + "午": "亥", "未": "甲", "申": "癸", "酉": "寅", + "戌": "丙", "亥": "乙", "子": "巳", "丑": "庚", +} + + +def calculate_shinsal(saju: dict) -> list[dict]: + """신살 계산 (역마/도화/화개/천을귀인/문창귀인/천덕귀인). + + TS calculateShinsal 와 1:1 동일. + """ + result: list[dict] = [] + day_branch = saju["day"]["branch"] + day_stem = saju["day_stem"] + month_branch = saju["month"]["branch"] + + pillar_branches: list[dict] = [ + {"branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"], "pillar": "년주"}, + {"branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"], "pillar": "월주"}, + {"branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"], "pillar": "일주"}, + ] + if saju.get("hour") is not None: + pillar_branches.append({ + "branch": saju["hour"]["branch"], + "branch_kr": saju["hour"]["branch_kr"], + "pillar": "시주", + }) + + group = _SAMHAP_GROUP_MAP.get(day_branch) + + if group: + # 역마살 + yeokma = _YEOKMA_MAP[group] + for pb in pillar_branches: + if pb["branch"] == yeokma and pb["pillar"] != "일주": + result.append({ + "name": "역마살", + "name_hanja": "驛馬殺", + "branch": yeokma, + "branch_kr": pb["branch_kr"], + "pillar": pb["pillar"], + "description": "이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.", + }) + + # 도화살 + dohwa = _DOHWA_MAP[group] + for pb in pillar_branches: + if pb["branch"] == dohwa and pb["pillar"] != "일주": + result.append({ + "name": "도화살", + "name_hanja": "桃花殺", + "branch": dohwa, + "branch_kr": pb["branch_kr"], + "pillar": pb["pillar"], + "description": "매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.", + }) + + # 화개살 + hwagae = _HWAGAE_MAP[group] + for pb in pillar_branches: + if pb["branch"] == hwagae and pb["pillar"] != "일주": + result.append({ + "name": "화개살", + "name_hanja": "華蓋殺", + "branch": hwagae, + "branch_kr": pb["branch_kr"], + "pillar": pb["pillar"], + "description": "학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.", + }) + + # 천을귀인 — 일간 기준 + cheonul_branches = _CHEONUL_MAP.get(day_stem, []) + for pb in pillar_branches: + if pb["branch"] in cheonul_branches and pb["pillar"] != "일주": + result.append({ + "name": "천을귀인", + "name_hanja": "天乙貴人", + "branch": pb["branch"], + "branch_kr": pb["branch_kr"], + "pillar": pb["pillar"], + "description": "위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.", + }) + + # 문창귀인 — 일간 기준 + munchang_branch = _MUNCHANG_MAP.get(day_stem) + if munchang_branch: + for pb in pillar_branches: + if pb["branch"] == munchang_branch and pb["pillar"] != "일주": + result.append({ + "name": "문창귀인", + "name_hanja": "文昌貴人", + "branch": pb["branch"], + "branch_kr": pb["branch_kr"], + "pillar": pb["pillar"], + "description": "학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.", + }) + + # 천덕귀인 — 월지 기준 천간이 4주 천간에 있으면 + cheonduk_stem = _CHEONDUK_MAP.get(month_branch) + if cheonduk_stem: + all_stems: list[dict] = [ + {"stem": saju["year"]["stem"], "pillar": "년주"}, + {"stem": saju["day"]["stem"], "pillar": "일주"}, + ] + if saju.get("hour") is not None: + all_stems.append({"stem": saju["hour"]["stem"], "pillar": "시주"}) + for ps in all_stems: + if ps["stem"] == cheonduk_stem: + br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(month_branch)] + result.append({ + "name": "천덕귀인", + "name_hanja": "天德貴人", + "branch": month_branch, + "branch_kr": br_kr, + "pillar": ps["pillar"], + "description": "하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.", + }) + + return result + + +# ─── 공망 ────────────────────────────────────────────────────────── +def calculate_gongmang(day_stem: str, day_branch: str) -> dict: + """공망(空亡) — 일주의 旬(60갑자 10단위)에서 빠지는 2개 지지. + + TS calculateGongmang 와 1:1 동일. + """ + stem_idx = HEAVENLY_STEMS.index(day_stem) + branch_idx = EARTHLY_BRANCHES.index(day_branch) + + # 60갑자에서 해당 旬의 시작점 = 천간이 甲인 지점 + # 旬의 시작 지지 인덱스 = (branchIdx - stemIdx + 120) % 12 + start_branch_idx = (branch_idx - stem_idx + 120) % 12 + + # 공망 = 旬 시작 + 10, +11 + gongmang1_idx = (start_branch_idx + 10) % 12 + gongmang2_idx = (start_branch_idx + 11) % 12 + + branch1 = EARTHLY_BRANCHES[gongmang1_idx] + branch2 = EARTHLY_BRANCHES[gongmang2_idx] + branch_kr1 = EARTHLY_BRANCHES_KR[gongmang1_idx] + branch_kr2 = EARTHLY_BRANCHES_KR[gongmang2_idx] + + return { + "branches": [branch1, branch2], + "branches_kr": [branch_kr1, branch_kr2], + "description": ( + f"{branch_kr1}({branch1})·{branch_kr2}({branch2}) 공망 " + f"→ 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. " + f"오히려 초월적 능력이 될 수 있음." + ), + } diff --git a/saju-lab/tests/test_shinsal.py b/saju-lab/tests/test_shinsal.py new file mode 100644 index 0000000..b8f82c6 --- /dev/null +++ b/saju-lab/tests/test_shinsal.py @@ -0,0 +1,156 @@ +"""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}" + )