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