Files
web-page-backend/saju-lab/tests/test_analysis.py
gahusb ebfade655a 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>
2026-05-25 20:00:36 +09:00

183 lines
6.9 KiB
Python

"""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}"