feat(saju-lab): shinsal.py — 지장간/신살/공망/지지 상호작용
This commit is contained in:
156
saju-lab/tests/test_shinsal.py
Normal file
156
saju-lab/tests/test_shinsal.py
Normal file
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user