Files
Jyotisha/tests/test_chart_view_points_api.py
T
ClaudeandClaude Opus 5.5 578a361460 feat(chart): functional roles, 7/8 Chara Karaka and three new 特殊点 groups (#163 1-3)
/api/chart_points:
- karaka_scheme 7 (default) / 8 / both; shared ranking in
  chart_view_points/karaka_rank.py (unrounded in-sign degree, Rahu reversed
  in the 8 scheme, Ketu out). Bodies equal at full precision mark only the
  roles they span as tied; Goel uses the same ranking (BUG-1512).
- functional_roles: v2 BPHS ch.34 profile laid out per body, nodes pending,
  Yogakaraka / Badhaka / Kendradhipati (conditions not evaluated) as
  separate labels.
- sensitive_points (Bhrigu Bindu per chart node_mode, 64th Navamsa, 22nd
  Drekkana sign-only), sahams (PVR Table 74, nine first, all kept),
  goel_points (printed-text policy, worked example beside it).

盘面数据: 行星 gets Chara Karaka (AK · 自我, 七星/八星 switch), 自然属性,
管辖宫位, 功能角色, 补充身份; 特殊点 splits into 特殊上升 / 敏感点 / Saham /
Goel 特殊点 and the board's 特殊点 layer follows the open group.

jaimini.py, functional_benefics.py and varga.py are untouched (rectification
frozen identity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-02 20:35:31 +08:00

247 lines
12 KiB
Python

"""`/api/chart_points` (issue #114): the chart page's data tables come from existing producers."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from chart_view_points.api import ChartPointsInputError, SIGNS, compute_chart_points # noqa: E402
# Real engine /api/chart output (1990-06-15 12:00 Beijing, raman), shared with the frontend tests.
GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8"))
BODY = {
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman",
"planets": GOLDEN["chart"]["planets"], "ascendant": GOLDEN["chart"]["ascendant"],
}
def _sign_index(longitude: float) -> int:
return int((longitude % 360) // 30)
def test_every_block_is_computed_from_the_engine_producers():
result = compute_chart_points(BODY)
assert result["success"] is True
assert result["upagrahas"]["status"] == "computed"
assert [row["key"] for row in result["upagrahas"]["rows"]][:5] == ["Dhuma", "Vyatipata", "Parivesha", "Indrachapa", "Upaketu"]
assert len(result["upagrahas"]["rows"]) == 11
assert result["special_lagnas"]["status"] == "computed"
assert len(result["special_lagnas"]["rows"]) == 8
assert [row["key"] for row in result["yogi_points"]["rows"]] == ["Yogi_Point", "Avayogi_Point"]
assert [row["key"] for row in result["arudha_padas"]["rows"]] == ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL"]
assert len(result["friendship"]["rows"]) == 9
def test_points_carry_sign_and_house_counted_from_the_ascendant():
result = compute_chart_points(BODY)
asc = _sign_index(float(BODY["ascendant"]["lon"]))
for block in ("upagrahas", "special_lagnas", "yogi_points"):
for row in result[block]["rows"]:
sign = _sign_index(row["longitude"])
assert row["sign"] == SIGNS[sign]
assert row["house"] == (sign - asc) % 12 + 1
assert 0 <= row["degree_in_sign"] < 30
def test_solar_upagrahas_follow_the_sun_formula():
sun = float(BODY["planets"]["Sun"]["lon"])
rows = {row["key"]: row["longitude"] for row in compute_chart_points(BODY)["upagrahas"]["rows"]}
dhuma = (sun + 133 + 20 / 60) % 360
assert rows["Dhuma"] == pytest.approx(dhuma, abs=1e-3)
assert rows["Vyatipata"] == pytest.approx((360 - dhuma) % 360, abs=1e-3)
assert rows["Parivesha"] == pytest.approx((rows["Vyatipata"] + 180) % 360, abs=1e-3)
def test_avayogi_is_yogi_plus_186_40():
rows = {row["key"]: row["longitude"] for row in compute_chart_points(BODY)["yogi_points"]["rows"]}
assert (rows["Avayogi_Point"] - rows["Yogi_Point"]) % 360 == pytest.approx(186 + 40 / 60, abs=1e-3)
def test_friendship_compound_is_natural_plus_temporary():
rows = compute_chart_points(BODY)["friendship"]["rows"]
sun = next(row for row in rows if row["planet"] == "Sun")
for target in sun["compound"]["great_friends"]:
assert target in sun["natural"]["friends"] and target in sun["temporary"]["friends"]
for target in sun["compound"]["great_enemies"]:
assert target in sun["natural"]["enemies"] and target in sun["temporary"]["enemies"]
def test_chara_karakas_rank_the_seven_by_in_sign_degree():
result = compute_chart_points(BODY)["chara_karakas"]
assert result["status"] == "computed"
assert result["scheme"] == "chara_7"
rows = result["rows"]
assert [row["key"] for row in rows] == ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"]
planets = BODY["planets"]
expected = sorted(
("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"),
key=lambda name: planets[name]["lon"] % 30,
reverse=True,
)
assert [row["planet"] for row in rows] == expected
def test_chara_karakas_mark_only_the_tied_roles():
planets = {name: dict(row) for name, row in BODY["planets"].items()}
planets["Moon"]["lon"] = planets["Sun"]["lon"] % 30 + 30 * int(planets["Moon"]["lon"] // 30)
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
# 原值: {"status": "blocked", "reason": "tied_degrees"},整列留空
# 新值: computed;只有 Sun / Moon 跨的两个角色标 tied,其余五个角色照常
# 原因: issue #163 占星师答复 2:同度不整列清空,只标「并列/角色待定」
assert result["status"] == "computed"
tied = [row for row in result["rows"] if row["tied"]]
assert [row["key"] for row in tied] == ["GK", "DK"]
assert all(row["planet"] is None and row["candidates"] == ["Sun", "Moon"] for row in tied)
assert result["ties"] == [{"planets": ["Sun", "Moon"], "roles": ["GK", "DK"], "sort_degree": pytest.approx(planets["Sun"]["lon"] % 30)}]
untied = [row for row in result["rows"] if not row["tied"]]
assert [row["planet"] for row in untied] == ["Venus", "Jupiter", "Mars", "Mercury", "Saturn"]
def test_chara_karakas_sort_by_unrounded_degree():
"""Equal at four decimals is not a tie; the old column blanked on rounded values."""
planets = {name: dict(row) for name, row in BODY["planets"].items()}
sign = 30 * int(planets["Moon"]["lon"] // 30)
planets["Moon"]["lon"] = sign + planets["Sun"]["lon"] % 30 + 0.00001
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
assert result["ties"] == []
assert [row["planet"] for row in result["rows"]][-2:] == ["Moon", "Sun"]
def test_karaka_scheme_parameter():
seven = compute_chart_points(BODY)
assert seven["chara_karakas"]["scheme"] == "chara_7"
assert "chara_karakas_8" not in seven
eight = compute_chart_points({**BODY, "karaka_scheme": "8"})["chara_karakas"]
assert eight["scheme"] == "chara_8"
assert [row["key"] for row in eight["rows"]] == ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"]
assert "Ketu" not in [row["planet"] for row in eight["rows"]]
rahu = next(row for row in eight["rows"] if row["planet"] == "Rahu")
assert rahu["sort_degree"] == pytest.approx(30 - BODY["planets"]["Rahu"]["lon"] % 30, abs=1e-6)
both = compute_chart_points({**BODY, "karaka_scheme": "both"})
assert both["chara_karakas"]["scheme"] == "chara_7"
assert both["chara_karakas_8"]["rows"] == eight["rows"]
with pytest.raises(ChartPointsInputError):
compute_chart_points({**BODY, "karaka_scheme": "9"})
def test_functional_roles_keep_profile_identities_and_pending_nodes():
result = compute_chart_points(BODY)["functional_roles"]
assert result["status"] == "computed"
assert result["ascendant"] == "Leo"
assert result["profile"] == "bphs_ch34_general_with_sign_exceptions_v2"
assert "jyotishvidya.com/ch34.htm" in result["translation"]
rows = {row["planet"]: row for row in result["rows"]}
assert list(rows) == ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
# Leo: Mars owns 4 and 9 (strict yogakaraka); fixed sign, so the Badhaka is the 9th lord, also Mars.
assert rows["Mars"]["functional"] == "benefic"
assert {"key": "yogakaraka"} in rows["Mars"]["identities"]
assert {"key": "badhaka", "house": 9} in rows["Mars"]["identities"]
# Venus (3, 10): natural benefic owning a kendra and no trine; conditions are not evaluated.
assert {"key": "kendradhipati", "status": "conditions_not_evaluated"} in rows["Venus"]["identities"]
assert rows["Venus"]["functional"] == "malefic" and rows["Venus"]["natural_functional_conflict"] is True
for node in ("Rahu", "Ketu"):
assert rows[node]["functional"] == "pending"
assert rows[node]["identities"] == []
assert rows["Moon"]["natural_condition"] == "lunar_phase_not_evaluated"
def test_sensitive_points_follow_the_chart_nodes_and_keep_sign_only_points():
result = compute_chart_points({**BODY, "node_mode": "mean"})["sensitive_points"]
assert result["status"] == "computed"
rows = {row["key"]: row for row in result["rows"]}
assert list(rows) == ["Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"]
rahu = BODY["planets"]["Rahu"]["lon"]
moon = BODY["planets"]["Moon"]["lon"]
assert rows["Bhrigu_Bindu"]["longitude"] == pytest.approx((rahu + ((moon - rahu) % 360) / 2) % 360, abs=1e-3)
assert rows["Bhrigu_Bindu"]["reference"] == {"from": "Rahu", "to": "Moon", "arc": "forward_rahu_to_moon", "node_mode": "mean"}
for key, anchor in (("Navamsa_64", "Moon"), ("Drekkana_22", "Lagna")):
assert rows[key]["precision"] == "sign"
assert "longitude" not in rows[key] and "degree_in_sign" not in rows[key]
assert rows[key]["reference"]["from"] == anchor
assert rows[key]["lord"]
def test_sahams_list_the_nine_first_and_keep_every_computed_row():
result = compute_chart_points(BODY)["sahams"]
assert result["status"] == "computed"
assert result["is_daytime"] is True # noon in Beijing
keys = [row["key"] for row in result["rows"]]
assert keys[:9] == [
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
]
assert [row["primary"] for row in result["rows"]].count(True) == 9
assert sum(row["rule_backed"] for row in result["rows"]) == 36
assert len(keys) == len(set(keys))
rows = {row["key"]: row for row in result["rows"]}
punya = rows["Punya_Saham"]
assert punya["formula_operands"] == ["Moon", "Sun", "Ascendant"] and punya["formula_variant"] == "day"
sun, moon, asc = (float(BODY["planets"]["Sun"]["lon"]), float(BODY["planets"]["Moon"]["lon"]), float(BODY["ascendant"]["lon"]))
expected = (moon - sun + asc) % 360 + (0 if (asc - sun) % 360 <= (moon - sun) % 360 else 30)
assert punya["longitude"] == pytest.approx(expected % 360, abs=1e-3)
assert rows["Karya_Saham"]["rule_backed"] is False
GOEL_SPLIT = {
"Sun": 102.29, "Moon": 123.1, "Mars": 90.57, "Mercury": 18.98, "Jupiter": 104.09,
"Venus": 127.86, "Saturn": 177.74, "Rahu": 120.14, "Ketu": 300.14,
}
def test_goel_points_default_to_the_printed_text_and_show_the_worked_example():
body = {**BODY, "planets": {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}, "ascendant": {"lon": 135.0}}
result = compute_chart_points(body)["goel_points"]
assert result["status"] == "computed"
assert (result["policy"], result["compare_policy"]) == ("include_nodes_text", "exclude_nodes_worked_example")
assert result["karaka_scheme"] == "chara_7"
rows = {row["point"]: row for row in result["rows"]}
brahma = rows["Brahma"]
assert (brahma["planet"], brahma["compare"]["planet"], brahma["differs"]) == ("Mercury", "Jupiter", True)
assert (brahma["sign"], brahma["house"]) == ("Aries", 9)
strength = result["lagna_seventh_strength"]
assert (strength["include_nodes_text"]["lagna"], strength["include_nodes_text"]["seventh"]) == (337.5, 352.5)
assert (strength["exclude_nodes_worked_example"]["lagna"], strength["exclude_nodes_worked_example"]["seventh"]) == (322.5, 292.5)
assert rows["Maheshwara"]["differs"] is False and rows["Rudra"]["differs"] is False
def test_goel_points_block_on_a_karaka_tie_and_say_which():
planets = {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}
planets["Moon"] = {"lon": 120 + GOEL_SPLIT["Sun"] % 30}
result = compute_chart_points({**BODY, "planets": planets, "ascendant": {"lon": 135.0}})["goel_points"]
assert result["status"] == "blocked"
assert result["karaka_ties"][0]["planets"] == ["Sun", "Moon"]
def test_one_failing_block_does_not_fail_the_response(monkeypatch):
import gulika
def broken(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(gulika, "calculate_upagrahas", broken)
result = compute_chart_points(BODY)
assert result["upagrahas"]["status"] == "blocked"
assert result["arudha_padas"]["status"] == "computed"
def test_missing_birth_or_chart_is_a_bad_request():
with pytest.raises(ChartPointsInputError):
compute_chart_points({**BODY, "planets": None})
with pytest.raises(ChartPointsInputError):
compute_chart_points({key: value for key, value in BODY.items() if key != "lat"})
def test_route_is_registered_thinly():
source = (SCRIPTS / "jyotish_api_server.py").read_text(encoding="utf-8")
assert "elif path == '/api/chart_points':" in source
assert "_load_local_module('chart_view_points.api')" in source