feat(saju-lab): analysis.py — 오행/신강신약/용신/세운 (30/30 reference)
- calculate_detailed_element_balance: 가중치 오행 점수 (천간 1.0, 본기 1.0, 중기 0.5, 여기 0.3) - calculate_element_score: 오행 비율 (%) — JS Math.round 호환 - analyze_day_master_strength: 신강/신약/중화 + score + reasons (월령 득령/실령, 통근, 투출, 오행 비율 4단계 평가) - estimate_yongshin: 용신/희신/기신 + explanation (신강 → 식상/재성/관살 중 약한 2개, 신약 → 인성/비겁 중 약한 것, 중화 → 5행 중 약한 2개) - calculate_seun: 올해 세운 + 4주 지지와 충/합 매핑 - perform_full_analysis: 위 5종 + branch_interactions + shinsal + gongmang + hidden_stems 통합 saju-web/lib/ai-interpretation.ts 와 1:1 매핑, 30 reference fixture 모두 통과 (180/180). 전체 saju-lab 테스트 389/389 passed. JS Math.round 와 Python round() 의 banker-rounding 차이 보정을 위해 _js_round 헬퍼 추가. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
saju-lab/tests/test_analysis.py
Normal file
182
saju-lab/tests/test_analysis.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""analysis.py — 오행 점수/신강신약/용신/세운/perform_full_analysis 검증.
|
||||
|
||||
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.analysis import (
|
||||
calculate_detailed_element_balance,
|
||||
calculate_element_score,
|
||||
analyze_day_master_strength,
|
||||
estimate_yongshin,
|
||||
calculate_seun,
|
||||
perform_full_analysis,
|
||||
)
|
||||
|
||||
|
||||
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. element_balance reference 매칭 ───────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_element_balance_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected = case["expected"]["analysis"]["elementBalance"]
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = calculate_detailed_element_balance(saju)
|
||||
|
||||
for elem in ["木", "火", "土", "金", "水"]:
|
||||
assert abs(actual[elem] - expected[elem]) < 0.01, (
|
||||
f"{inp}: element_balance.{elem} differ: {actual[elem]} vs {expected[elem]}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 2. element_scores reference 매칭 ────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_element_score_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected = case["expected"]["analysis"]["elementScores"]
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = calculate_element_score(saju)
|
||||
|
||||
for elem, expected_pct in expected.items():
|
||||
actual_pct = actual.get(elem, 0)
|
||||
assert actual_pct == expected_pct, (
|
||||
f"{inp}: element_scores.{elem}: {actual_pct} vs {expected_pct}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 3. day_master_strength reference 매칭 ───────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_day_master_strength_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected = case["expected"]["analysis"]["dayMasterStrength"]
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = analyze_day_master_strength(saju)
|
||||
|
||||
assert actual["result"] == expected["result"], (
|
||||
f"{inp}: result mismatch: {actual['result']} vs {expected['result']}"
|
||||
)
|
||||
assert actual["score"] == expected["score"], (
|
||||
f"{inp}: score mismatch: {actual['score']} vs {expected['score']}"
|
||||
)
|
||||
assert actual["reasons"] == expected["reasons"], (
|
||||
f"{inp}: reasons mismatch:\n actual={actual['reasons']}\n expected={expected['reasons']}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 4. yong_shin reference 매칭 ─────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_yong_shin_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"]["yongShin"]
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
strength = analyze_day_master_strength(saju)
|
||||
actual = estimate_yongshin(saju, strength)
|
||||
|
||||
assert actual["yong_shin"] == expected["yong_shin"]
|
||||
assert actual["yong_shin_kr"] == expected["yong_shin_kr"]
|
||||
assert actual["hee_shin"] == expected["hee_shin"]
|
||||
assert actual["hee_shin_kr"] == expected["hee_shin_kr"]
|
||||
assert actual["gi_shin"] == expected["gi_shin"]
|
||||
assert actual["gi_shin_kr"] == expected["gi_shin_kr"]
|
||||
assert actual["explanation"] == expected["explanation"]
|
||||
|
||||
|
||||
# ─── 5. seun reference 매칭 ─────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_seun_match_reference(case):
|
||||
inp = case["input"]
|
||||
expected_raw = case["expected"]["analysis"]["seun"]
|
||||
expected = _normalize(expected_raw)
|
||||
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = calculate_seun(2026, saju)
|
||||
|
||||
assert actual["stem"] == expected["stem"]
|
||||
assert actual["branch"] == expected["branch"]
|
||||
assert actual["stem_kr"] == expected["stem_kr"]
|
||||
assert actual["branch_kr"] == expected["branch_kr"]
|
||||
assert actual["element"] == expected["element"]
|
||||
assert actual["element_kr"] == expected["element_kr"]
|
||||
assert actual["year"] == expected["year"]
|
||||
# interactions — 순서 무관 비교
|
||||
actual_iter = actual.get("interactions", [])
|
||||
expected_iter = expected.get("interactions", [])
|
||||
assert sorted(map(_canonical, actual_iter)) == sorted(map(_canonical, expected_iter)), (
|
||||
f"{inp}: seun interactions mismatch:\n actual={actual_iter}\n expected={expected_iter}"
|
||||
)
|
||||
|
||||
|
||||
# ─── 6. perform_full_analysis 통합 ───────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
REF,
|
||||
ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}",
|
||||
)
|
||||
def test_perform_full_analysis_keys(case):
|
||||
"""perform_full_analysis 가 모든 expected 키를 반환하는지 확인."""
|
||||
inp = case["input"]
|
||||
saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"])
|
||||
actual = perform_full_analysis(saju, 2026)
|
||||
|
||||
# 통합 결과에 필수 키 모두 존재
|
||||
for key in [
|
||||
"element_balance", "element_scores", "day_master_strength", "yong_shin",
|
||||
"branch_interactions", "shinsal", "gongmang", "seun", "hidden_stems",
|
||||
]:
|
||||
assert key in actual, f"{inp}: missing key {key}"
|
||||
Reference in New Issue
Block a user