feat(qizheng): vendor stem-branch and expose three read-only chart APIs
Add Apache-2.0 @4n6h4x0r/stem-branch 0.8.0, a seven-governors adapter, and POST /api/qizheng, /api/western, /api/ephemeris_events. BUG-700 remains blocked (do not call --pillars). BUG-701 and BUG-702 are resolved. BUG-703 is investigating (panchanga Lahiri). Skill is not bumped.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
[
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-09-16",
|
||||
"body": "mars",
|
||||
"from_sign": "Gemini",
|
||||
"to_sign": "Cancer",
|
||||
"longitude": 90.253712,
|
||||
"speed_longitude": 0.60948957
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-09-16",
|
||||
"body": "sun",
|
||||
"from_sign": "Leo",
|
||||
"to_sign": "Virgo",
|
||||
"longitude": 150.861854,
|
||||
"speed_longitude": 0.97511766
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-09-25",
|
||||
"body": "mercury",
|
||||
"from_sign": "Virgo",
|
||||
"to_sign": "Libra",
|
||||
"longitude": 180.311964,
|
||||
"speed_longitude": 1.4292821
|
||||
},
|
||||
{
|
||||
"kind": "station",
|
||||
"date": "2026-10-03",
|
||||
"body": "venus",
|
||||
"motion": "direct_to_retrograde",
|
||||
"longitude": 195.703466,
|
||||
"speed_longitude": -0.00772084
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-10-16",
|
||||
"body": "sun",
|
||||
"from_sign": "Virgo",
|
||||
"to_sign": "Libra",
|
||||
"longitude": 180.356696,
|
||||
"speed_longitude": 0.99171609
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-10-21",
|
||||
"body": "jupiter",
|
||||
"from_sign": "Cancer",
|
||||
"to_sign": "Leo",
|
||||
"longitude": 120.138144,
|
||||
"speed_longitude": 0.14408014
|
||||
},
|
||||
{
|
||||
"kind": "station",
|
||||
"date": "2026-10-24",
|
||||
"body": "mercury",
|
||||
"motion": "direct_to_retrograde",
|
||||
"longitude": 208.188834,
|
||||
"speed_longitude": -0.02657353
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-11-10",
|
||||
"body": "mars",
|
||||
"from_sign": "Cancer",
|
||||
"to_sign": "Leo",
|
||||
"longitude": 120.461344,
|
||||
"speed_longitude": 0.46947876
|
||||
},
|
||||
{
|
||||
"kind": "station",
|
||||
"date": "2026-11-14",
|
||||
"body": "mercury",
|
||||
"motion": "retrograde_to_direct",
|
||||
"longitude": 192.309394,
|
||||
"speed_longitude": 0.15235781
|
||||
},
|
||||
{
|
||||
"kind": "station",
|
||||
"date": "2026-11-14",
|
||||
"body": "venus",
|
||||
"motion": "retrograde_to_direct",
|
||||
"longitude": 180.079847,
|
||||
"speed_longitude": 0.01920644
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-11-15",
|
||||
"body": "sun",
|
||||
"from_sign": "Libra",
|
||||
"to_sign": "Scorpio",
|
||||
"longitude": 210.345638,
|
||||
"speed_longitude": 1.0072546
|
||||
},
|
||||
{
|
||||
"kind": "ingress",
|
||||
"date": "2026-12-02",
|
||||
"body": "mercury",
|
||||
"from_sign": "Libra",
|
||||
"to_sign": "Scorpio",
|
||||
"longitude": 211.44883,
|
||||
"speed_longitude": 1.47308331
|
||||
},
|
||||
{
|
||||
"kind": "station",
|
||||
"date": "2026-12-11",
|
||||
"body": "saturn",
|
||||
"motion": "retrograde_to_direct",
|
||||
"longitude": 345.141446,
|
||||
"speed_longitude": 0.00087981
|
||||
},
|
||||
{
|
||||
"kind": "station",
|
||||
"date": "2026-12-13",
|
||||
"body": "jupiter",
|
||||
"motion": "direct_to_retrograde",
|
||||
"longitude": 124.234351,
|
||||
"speed_longitude": -0.00156979
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,378 @@
|
||||
{
|
||||
"date": "1990-04-09T05:24:00.000Z",
|
||||
"location": {
|
||||
"lat": 31.19,
|
||||
"lon": 121.44
|
||||
},
|
||||
"siderealMode": {
|
||||
"type": "modern"
|
||||
},
|
||||
"ketuMode": "apogee",
|
||||
"bodies": {
|
||||
"sun": {
|
||||
"siderealLon": 177.94225865978694,
|
||||
"tropicalLon": 19.104629395697632,
|
||||
"mansion": "奎",
|
||||
"mansionDegree": 7.442258659786944,
|
||||
"palace": "亥宮"
|
||||
},
|
||||
"moon": {
|
||||
"siderealLon": 347.80169662965875,
|
||||
"tropicalLon": 188.96406736556946,
|
||||
"mansion": "翼",
|
||||
"mansionDegree": 15.80169662965875,
|
||||
"palace": "巳宮"
|
||||
},
|
||||
"mercury": {
|
||||
"siderealLon": 196.51388762098574,
|
||||
"tropicalLon": 37.676258356896426,
|
||||
"mansion": "婁",
|
||||
"mansionDegree": 10.01388762098577,
|
||||
"palace": "戌宮"
|
||||
},
|
||||
"venus": {
|
||||
"siderealLon": 131.7721831286916,
|
||||
"tropicalLon": 332.9345538646023,
|
||||
"mansion": "危",
|
||||
"mansionDegree": 2.7721831286916085,
|
||||
"palace": "子宮"
|
||||
},
|
||||
"mars": {
|
||||
"siderealLon": 120.09189087464438,
|
||||
"tropicalLon": 321.2542616105551,
|
||||
"mansion": "虛",
|
||||
"mansionDegree": 1.0918908746443776,
|
||||
"palace": "子宮"
|
||||
},
|
||||
"jupiter": {
|
||||
"siderealLon": 252.55210865864606,
|
||||
"tropicalLon": 93.71447939455675,
|
||||
"mansion": "參",
|
||||
"mansionDegree": 12.552108658646034,
|
||||
"palace": "申宮"
|
||||
},
|
||||
"saturn": {
|
||||
"siderealLon": 93.63791030757659,
|
||||
"tropicalLon": 294.8002810434873,
|
||||
"mansion": "斗",
|
||||
"mansionDegree": 20.13791030757659,
|
||||
"palace": "丑宮"
|
||||
},
|
||||
"rahu": {
|
||||
"siderealLon": 112.09440711057766,
|
||||
"tropicalLon": 313.2567778464884,
|
||||
"mansion": "女",
|
||||
"mansionDegree": 5.094407110577663,
|
||||
"palace": "丑宮"
|
||||
},
|
||||
"ketu": {
|
||||
"siderealLon": 27.460314779445753,
|
||||
"tropicalLon": 228.62268551535647,
|
||||
"mansion": "亢",
|
||||
"mansionDegree": 15.460314779445753,
|
||||
"palace": "辰宮"
|
||||
},
|
||||
"yuebei": {
|
||||
"siderealLon": 26.232089970448214,
|
||||
"tropicalLon": 227.39446070635893,
|
||||
"mansion": "亢",
|
||||
"mansionDegree": 14.232089970448214,
|
||||
"palace": "辰宮"
|
||||
},
|
||||
"purpleQi": {
|
||||
"siderealLon": 33.337261454764985,
|
||||
"tropicalLon": 234.49963219067564,
|
||||
"mansion": "房",
|
||||
"mansionDegree": 0.3372614547649846,
|
||||
"palace": "卯宮"
|
||||
}
|
||||
},
|
||||
"palaces": [
|
||||
{
|
||||
"name": "辰宮",
|
||||
"role": "妻妾宮",
|
||||
"mansions": [
|
||||
"角",
|
||||
"亢"
|
||||
],
|
||||
"occupants": [
|
||||
"ketu",
|
||||
"yuebei"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "卯宮",
|
||||
"role": "男女宮",
|
||||
"mansions": [
|
||||
"氐",
|
||||
"房",
|
||||
"心"
|
||||
],
|
||||
"occupants": [
|
||||
"purpleQi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "寅宮",
|
||||
"role": "財帛宮",
|
||||
"mansions": [
|
||||
"尾",
|
||||
"箕"
|
||||
],
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"name": "丑宮",
|
||||
"role": "疾厄宮",
|
||||
"mansions": [
|
||||
"斗",
|
||||
"牛"
|
||||
],
|
||||
"occupants": [
|
||||
"saturn",
|
||||
"rahu"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "子宮",
|
||||
"role": "遷移宮",
|
||||
"mansions": [
|
||||
"女",
|
||||
"虛",
|
||||
"危"
|
||||
],
|
||||
"occupants": [
|
||||
"venus",
|
||||
"mars"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "亥宮",
|
||||
"role": "奴僕宮",
|
||||
"mansions": [
|
||||
"室",
|
||||
"壁"
|
||||
],
|
||||
"occupants": [
|
||||
"sun"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "戌宮",
|
||||
"role": "官祿宮",
|
||||
"mansions": [
|
||||
"奎",
|
||||
"婁",
|
||||
"胃"
|
||||
],
|
||||
"occupants": [
|
||||
"mercury"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "酉宮",
|
||||
"role": "田宅宮",
|
||||
"mansions": [
|
||||
"昴",
|
||||
"畢"
|
||||
],
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"name": "申宮",
|
||||
"role": "福德宮",
|
||||
"mansions": [
|
||||
"觜",
|
||||
"參"
|
||||
],
|
||||
"occupants": [
|
||||
"jupiter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "未宮",
|
||||
"role": "相貌宮",
|
||||
"mansions": [
|
||||
"井",
|
||||
"鬼"
|
||||
],
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"name": "午宮",
|
||||
"role": "命宮",
|
||||
"mansions": [
|
||||
"柳",
|
||||
"星",
|
||||
"張"
|
||||
],
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"name": "巳宮",
|
||||
"role": "兄弟宮",
|
||||
"mansions": [
|
||||
"翼",
|
||||
"軫"
|
||||
],
|
||||
"occupants": [
|
||||
"moon"
|
||||
]
|
||||
}
|
||||
],
|
||||
"ascendant": {
|
||||
"mansion": "星",
|
||||
"palace": "午宮"
|
||||
},
|
||||
"starSpirits": [],
|
||||
"aspects": [
|
||||
{
|
||||
"body1": "sun",
|
||||
"body2": "moon",
|
||||
"type": "沖"
|
||||
},
|
||||
{
|
||||
"body1": "sun",
|
||||
"body2": "jupiter",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "sun",
|
||||
"body2": "purpleQi",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "moon",
|
||||
"body2": "jupiter",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "moon",
|
||||
"body2": "saturn",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "moon",
|
||||
"body2": "rahu",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "mercury",
|
||||
"body2": "saturn",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "mercury",
|
||||
"body2": "rahu",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "mercury",
|
||||
"body2": "ketu",
|
||||
"type": "沖"
|
||||
},
|
||||
{
|
||||
"body1": "mercury",
|
||||
"body2": "yuebei",
|
||||
"type": "沖"
|
||||
},
|
||||
{
|
||||
"body1": "venus",
|
||||
"body2": "mars",
|
||||
"type": "合"
|
||||
},
|
||||
{
|
||||
"body1": "venus",
|
||||
"body2": "jupiter",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "venus",
|
||||
"body2": "ketu",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "venus",
|
||||
"body2": "yuebei",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "venus",
|
||||
"body2": "purpleQi",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "mars",
|
||||
"body2": "jupiter",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "mars",
|
||||
"body2": "ketu",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "mars",
|
||||
"body2": "yuebei",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "mars",
|
||||
"body2": "purpleQi",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "jupiter",
|
||||
"body2": "ketu",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "jupiter",
|
||||
"body2": "yuebei",
|
||||
"type": "三合"
|
||||
},
|
||||
{
|
||||
"body1": "saturn",
|
||||
"body2": "rahu",
|
||||
"type": "合"
|
||||
},
|
||||
{
|
||||
"body1": "saturn",
|
||||
"body2": "ketu",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "saturn",
|
||||
"body2": "yuebei",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "rahu",
|
||||
"body2": "ketu",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "rahu",
|
||||
"body2": "yuebei",
|
||||
"type": "刑"
|
||||
},
|
||||
{
|
||||
"body1": "ketu",
|
||||
"body2": "yuebei",
|
||||
"type": "合"
|
||||
}
|
||||
],
|
||||
"dignities": {
|
||||
"sun": "陷",
|
||||
"moon": "平",
|
||||
"mercury": "平",
|
||||
"venus": "平",
|
||||
"mars": "平",
|
||||
"jupiter": "平",
|
||||
"saturn": "平",
|
||||
"rahu": "平",
|
||||
"ketu": "平",
|
||||
"yuebei": "平",
|
||||
"purpleQi": "平"
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,9 @@ def test_health_and_light_paths_are_not_gated() -> None:
|
||||
assert is_heavy_compute_path("/api/rectification/sensitivity_scan")
|
||||
assert is_heavy_compute_path("/api/vedastro_gateway/jobs/job1/run")
|
||||
assert "/api/chart" not in HEAVY_COMPUTE_PATHS
|
||||
assert is_heavy_compute_path("/api/qizheng")
|
||||
assert is_heavy_compute_path("/api/western")
|
||||
assert is_heavy_compute_path("/api/ephemeris_events")
|
||||
assert DEFAULT_CONCURRENCY == 2
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Module tests for sidereal ingress and station scanning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.ephemeris_events import EphemerisEventsError, build_ephemeris_events
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GOLDEN = ROOT / "tests" / "golden" / "ephemeris_events_raman_20260915_90d.json"
|
||||
WINDOW = {
|
||||
"start_date": "2026-09-15",
|
||||
"end_date": "2026-12-14",
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
}
|
||||
|
||||
|
||||
def test_raman_90_day_scan_matches_live_golden_and_has_both_kinds() -> None:
|
||||
result = build_ephemeris_events(WINDOW)
|
||||
if not GOLDEN.exists():
|
||||
GOLDEN.write_text(json.dumps(result["events"], ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
||||
assert result["events"] == golden
|
||||
assert len(result["events"]) == 14
|
||||
assert {item["kind"] for item in result["events"]} == {"ingress", "station"}
|
||||
assert [item["date"] for item in result["events"]] == sorted(item["date"] for item in result["events"])
|
||||
assert result["calculation"]["ayanamsa"] == "raman"
|
||||
|
||||
|
||||
def test_lahiri_differs_from_raman_on_the_same_window() -> None:
|
||||
raman = build_ephemeris_events(WINDOW)
|
||||
lahiri = build_ephemeris_events({**WINDOW, "ayanamsa": "lahiri"})
|
||||
assert raman["events"] != lahiri["events"]
|
||||
assert lahiri["calculation"]["ayanamsa"] == "lahiri"
|
||||
|
||||
|
||||
def test_range_over_730_days_is_rejected() -> None:
|
||||
with pytest.raises(EphemerisEventsError, match="730"):
|
||||
build_ephemeris_events({"start_date": "2020-01-01", "end_date": "2022-01-02", "ayanamsa": "raman"})
|
||||
|
||||
|
||||
def test_unsupported_ayanamsa_is_rejected() -> None:
|
||||
with pytest.raises(EphemerisEventsError, match="unsupported ayanamsa"):
|
||||
build_ephemeris_events({**WINDOW, "ayanamsa": "not-a-real-ayanamsa"})
|
||||
@@ -0,0 +1,122 @@
|
||||
"""HTTP contract for POST /api/qizheng."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.api_heavy_compute_gate import reset_heavy_compute_gate
|
||||
from scripts.jyotish_api_server import (
|
||||
DEFAULT_ALLOWED_HOSTS,
|
||||
DEFAULT_ALLOWED_ORIGINS,
|
||||
JyotishAPIHandler,
|
||||
)
|
||||
|
||||
SAMPLE = {
|
||||
"year": 1990,
|
||||
"month": 4,
|
||||
"day": 9,
|
||||
"hour": 13,
|
||||
"minute": 24,
|
||||
"lat": 31.19,
|
||||
"lon": 121.44,
|
||||
"tz": 8,
|
||||
}
|
||||
|
||||
|
||||
class _FakeHeaders(dict):
|
||||
def get(self, key, default=None):
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
allowed_origins = DEFAULT_ALLOWED_ORIGINS
|
||||
allowed_hosts = DEFAULT_ALLOWED_HOSTS
|
||||
|
||||
|
||||
class _PostCaptureHandler(JyotishAPIHandler):
|
||||
def __init__(self, path: str, payload: dict) -> None:
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
self.headers = _FakeHeaders(
|
||||
{
|
||||
"Content-Length": str(len(raw)),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
self.server = _FakeServer()
|
||||
self.path = path
|
||||
self.rfile = BytesIO(raw)
|
||||
self.wfile = BytesIO()
|
||||
self.status_code = None
|
||||
self.response_headers = []
|
||||
self.client_address = ("test-qizheng", 0)
|
||||
|
||||
def send_response(self, code, message=None): # noqa: ANN001
|
||||
self.status_code = code
|
||||
|
||||
def send_header(self, key, value): # noqa: ANN001
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
return None
|
||||
|
||||
def payload(self) -> dict:
|
||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_env(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "0")
|
||||
reset_heavy_compute_gate()
|
||||
yield
|
||||
reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_qizheng_endpoint_returns_eleven_bodies_and_twelve_palaces(api_env) -> None:
|
||||
handler = _PostCaptureHandler("/api/qizheng", SAMPLE)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200
|
||||
payload = handler.payload()
|
||||
assert payload["success"] is True
|
||||
assert payload["coordinate_system"] == "qizheng_mansion_degrees_from_jiao"
|
||||
assert len(payload["bodies"]) == 11
|
||||
assert len(payload["palaces"]) == 12
|
||||
assert payload["ascendant"]["palace"] == "午宮"
|
||||
assert payload["dignities"]["status"] == "unclosed"
|
||||
assert "神煞" not in payload["boundary"]
|
||||
|
||||
|
||||
def test_qizheng_missing_lat_returns_400(api_env) -> None:
|
||||
body = {key: value for key, value in SAMPLE.items() if key != "lat"}
|
||||
handler = _PostCaptureHandler("/api/qizheng", body)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 400
|
||||
payload = handler.payload()
|
||||
assert payload["success"] is False
|
||||
assert payload["error_code"] == "ERR_BAD_REQUEST"
|
||||
assert "lat" in payload["error"]
|
||||
|
||||
|
||||
def test_qizheng_missing_lon_returns_400(api_env) -> None:
|
||||
body = {key: value for key, value in SAMPLE.items() if key != "lon"}
|
||||
handler = _PostCaptureHandler("/api/qizheng", body)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 400
|
||||
assert handler.payload()["success"] is False
|
||||
|
||||
|
||||
def test_qizheng_node_unavailable_returns_structured_error(api_env, monkeypatch) -> None:
|
||||
from scripts.jyotish_api_server import _load_local_module
|
||||
|
||||
module = _load_local_module("qizheng_chart_engine")
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: None)
|
||||
handler = _PostCaptureHandler("/api/qizheng", SAMPLE)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 400
|
||||
payload = handler.payload()
|
||||
assert payload["success"] is False
|
||||
assert payload["error_code"] == "ERR_BAD_REQUEST"
|
||||
assert "node" in payload["error"]
|
||||
assert "Traceback" not in payload["error"]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Adapter tests for the vendored seven-governors engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import qizheng_chart_engine as engine
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GOLDEN = ROOT / "tests" / "golden" / "qizheng_stem_branch_19900409.json"
|
||||
SAMPLE = {
|
||||
"year": 1990,
|
||||
"month": 4,
|
||||
"day": 9,
|
||||
"hour": 13,
|
||||
"minute": 24,
|
||||
"lat": 31.19,
|
||||
"lon": 121.44,
|
||||
"tz": 8,
|
||||
}
|
||||
|
||||
|
||||
def _require_node() -> None:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node runtime is not available")
|
||||
|
||||
|
||||
def test_golden_fixture_is_real_engine_output() -> None:
|
||||
chart = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
||||
assert set(chart["bodies"]) == set(engine.REQUIRED_BODIES)
|
||||
assert len(chart["palaces"]) == 12
|
||||
assert chart["ascendant"]["palace"] == "午宮"
|
||||
assert chart["ketuMode"] == "apogee"
|
||||
sun = chart["bodies"]["sun"]
|
||||
assert sun["mansion"] == "奎"
|
||||
assert round(sun["siderealLon"], 2) == 177.94
|
||||
assert round(sun["mansionDegree"], 2) == 7.44
|
||||
assert chart["dignities"]["sun"] == "陷"
|
||||
assert chart["starSpirits"] == []
|
||||
assert len(chart["aspects"]) == 27
|
||||
|
||||
|
||||
def test_live_chart_matches_golden_and_product_contract() -> None:
|
||||
_require_node()
|
||||
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
||||
result = engine.build_qizheng_natal_chart(SAMPLE)
|
||||
assert result["coordinate_system"] == "qizheng_mansion_degrees_from_jiao"
|
||||
assert set(result["bodies"]) == set(engine.REQUIRED_BODIES)
|
||||
assert len(result["palaces"]) == 12
|
||||
assert result["ascendant"]["palace"] == golden["ascendant"]["palace"]
|
||||
assert result["calculation"]["ketu_mode"] == "apogee"
|
||||
assert result["calculation"]["sidereal_mode"] == {"type": "modern"}
|
||||
assert result["dignities"]["status"] == "unclosed"
|
||||
assert result["dignities"]["may_enter_conclusions"] is False
|
||||
assert result["dignities"]["runtime_promotable_count"] == 0
|
||||
assert "神煞" not in result["boundary"]
|
||||
assert "未闭合" in result["boundary"] or "未闭合" in result["dignities"]["status"] or result["dignities"]["status"] == "unclosed"
|
||||
assert round(result["bodies"]["sun"]["siderealLon"], 2) == round(golden["bodies"]["sun"]["siderealLon"], 2)
|
||||
raw = json.dumps(result["raw_engine_output"], ensure_ascii=False)
|
||||
assert "vendor" not in raw
|
||||
assert "stem-branch" not in raw or result["raw_engine_output"].get("ketuMode") == "apogee"
|
||||
assert "\\" not in raw or "[redacted-path]" in raw
|
||||
|
||||
|
||||
def test_ketu_mode_request_overrides_and_is_echoed() -> None:
|
||||
_require_node()
|
||||
result = engine.build_qizheng_natal_chart({**SAMPLE, "ketu_mode": "descending-node"})
|
||||
assert result["calculation"]["ketu_mode"] == "descending-node"
|
||||
assert result["calculation"]["engine_ketu_mode"] == "descending-node"
|
||||
rahu = result["bodies"]["rahu"]["siderealLon"]
|
||||
ketu = result["bodies"]["ketu"]["siderealLon"]
|
||||
separation = abs((ketu - rahu) % 360)
|
||||
assert min(separation, 360 - separation) > 170
|
||||
|
||||
|
||||
def test_missing_lat_is_structured_input_error() -> None:
|
||||
with pytest.raises(engine.QizhengChartError, match="lat is required") as caught:
|
||||
engine.build_qizheng_natal_chart({k: v for k, v in SAMPLE.items() if k != "lat"})
|
||||
assert caught.value.error_code == "ERR_QIZHENG_INPUT"
|
||||
|
||||
|
||||
def test_node_missing_error(monkeypatch) -> None:
|
||||
monkeypatch.setattr(engine.shutil, "which", lambda name: None)
|
||||
with pytest.raises(engine.QizhengChartError, match="node runtime is not available") as caught:
|
||||
engine.build_qizheng_natal_chart(SAMPLE)
|
||||
assert caught.value.error_code == "ERR_QIZHENG_NODE_MISSING"
|
||||
|
||||
|
||||
def test_cli_missing_error(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(engine, "CLI_PATH", tmp_path / "cli.cjs")
|
||||
monkeypatch.setattr(engine, "LIB_PATH", tmp_path / "index.cjs")
|
||||
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
|
||||
with pytest.raises(engine.QizhengChartError, match="missing") as caught:
|
||||
engine.build_qizheng_natal_chart(SAMPLE)
|
||||
assert caught.value.error_code == "ERR_QIZHENG_CLI_MISSING"
|
||||
|
||||
|
||||
def test_timeout_error(monkeypatch) -> None:
|
||||
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
|
||||
|
||||
def _boom(*args, **kwargs): # noqa: ANN001
|
||||
raise subprocess.TimeoutExpired(cmd="node", timeout=20)
|
||||
|
||||
monkeypatch.setattr(engine.subprocess, "run", _boom)
|
||||
with pytest.raises(engine.QizhengChartError, match="timed out") as caught:
|
||||
engine.build_qizheng_natal_chart(SAMPLE)
|
||||
assert caught.value.error_code == "ERR_QIZHENG_TIMEOUT"
|
||||
|
||||
|
||||
def test_nonzero_exit_does_not_leak_stderr(monkeypatch) -> None:
|
||||
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
|
||||
monkeypatch.setattr(
|
||||
engine.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
returncode=1, stdout="", stderr="C:\\\\secret\\\\stem-branch\\\\cli.cjs exploded"
|
||||
),
|
||||
)
|
||||
with pytest.raises(engine.QizhengChartError, match="exited with an error") as caught:
|
||||
engine.build_qizheng_natal_chart(SAMPLE)
|
||||
assert caught.value.error_code == "ERR_QIZHENG_ENGINE_EXIT"
|
||||
assert "secret" not in str(caught.value)
|
||||
assert "cli.cjs" not in str(caught.value)
|
||||
|
||||
|
||||
def test_non_json_stdout_error(monkeypatch) -> None:
|
||||
monkeypatch.setattr(engine.shutil, "which", lambda name: "node")
|
||||
monkeypatch.setattr(
|
||||
engine.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="not-json", stderr=""),
|
||||
)
|
||||
with pytest.raises(engine.QizhengChartError, match="non-JSON") as caught:
|
||||
engine.build_qizheng_natal_chart(SAMPLE)
|
||||
assert caught.value.error_code == "ERR_QIZHENG_BAD_JSON"
|
||||
|
||||
|
||||
def test_source_never_invokes_forbidden_cli_flags() -> None:
|
||||
assert "--pillars" not in engine._NODE_EVAL
|
||||
assert "--luck" not in engine._NODE_EVAL
|
||||
assert "--seven-governors" not in engine._NODE_EVAL
|
||||
assert "getSevenGovernorsChart" in engine._NODE_EVAL
|
||||
argv = ["node", "-e", engine._NODE_EVAL]
|
||||
assert not engine.FORBIDDEN_CLI_FLAGS.intersection(argv)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""HTTP contracts for POST /api/western and POST /api/ephemeris_events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.api_heavy_compute_gate import reset_heavy_compute_gate
|
||||
from scripts.jyotish_api_server import (
|
||||
DEFAULT_ALLOWED_HOSTS,
|
||||
DEFAULT_ALLOWED_ORIGINS,
|
||||
JyotishAPIHandler,
|
||||
)
|
||||
|
||||
SAMPLE = {
|
||||
"year": 1990,
|
||||
"month": 4,
|
||||
"day": 9,
|
||||
"hour": 13,
|
||||
"minute": 24,
|
||||
"lat": 31.19,
|
||||
"lon": 121.44,
|
||||
"tz": 8,
|
||||
}
|
||||
|
||||
|
||||
class _FakeHeaders(dict):
|
||||
def get(self, key, default=None):
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
allowed_origins = DEFAULT_ALLOWED_ORIGINS
|
||||
allowed_hosts = DEFAULT_ALLOWED_HOSTS
|
||||
|
||||
|
||||
class _PostCaptureHandler(JyotishAPIHandler):
|
||||
def __init__(self, path: str, payload: dict) -> None:
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
self.headers = _FakeHeaders(
|
||||
{
|
||||
"Content-Length": str(len(raw)),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
self.server = _FakeServer()
|
||||
self.path = path
|
||||
self.rfile = BytesIO(raw)
|
||||
self.wfile = BytesIO()
|
||||
self.status_code = None
|
||||
self.response_headers = []
|
||||
self.client_address = ("test-readonly-chart", 0)
|
||||
|
||||
def send_response(self, code, message=None): # noqa: ANN001
|
||||
self.status_code = code
|
||||
|
||||
def send_header(self, key, value): # noqa: ANN001
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
return None
|
||||
|
||||
def payload(self) -> dict:
|
||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_env(monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "0")
|
||||
reset_heavy_compute_gate()
|
||||
yield
|
||||
reset_heavy_compute_gate()
|
||||
|
||||
|
||||
def test_western_returns_planets_cusps_aspects_and_distribution(api_env) -> None:
|
||||
handler = _PostCaptureHandler("/api/western", SAMPLE)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200
|
||||
payload = handler.payload()
|
||||
natal = payload["natal"]
|
||||
assert payload["zodiac"] == "tropical"
|
||||
assert payload["house_system"] == "P"
|
||||
assert payload["coordinate_system"] == "tropical_ecliptic"
|
||||
assert len(natal["planets"]) == 11
|
||||
assert len(natal["houses"]) == 12
|
||||
assert natal["aspects"]
|
||||
assert set(natal["distribution"]["elements"]) == {"fire", "earth", "air", "water"}
|
||||
assert "岁差" in payload["boundary"] or "ayanamsa" in json.dumps(payload["sidereal_offset_degrees"])
|
||||
|
||||
|
||||
def test_western_house_system_can_be_overridden_and_echoed(api_env) -> None:
|
||||
handler = _PostCaptureHandler("/api/western", {**SAMPLE, "house_system": "K"})
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200
|
||||
payload = handler.payload()
|
||||
assert payload["house_system"] == "K"
|
||||
placidus = _PostCaptureHandler("/api/western", SAMPLE)
|
||||
placidus.do_POST()
|
||||
assert placidus.payload()["house_system"] == "P"
|
||||
assert (
|
||||
placidus.payload()["natal"]["houses"][2]["cusp"]["longitude"]
|
||||
!= payload["natal"]["houses"][2]["cusp"]["longitude"]
|
||||
)
|
||||
|
||||
|
||||
def test_ephemeris_events_90_day_window_has_sorted_ingress_and_station(api_env) -> None:
|
||||
handler = _PostCaptureHandler(
|
||||
"/api/ephemeris_events",
|
||||
{
|
||||
"start_date": "2026-09-15",
|
||||
"end_date": "2026-12-14",
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
},
|
||||
)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 200
|
||||
payload = handler.payload()
|
||||
events = payload["events"]
|
||||
kinds = {item["kind"] for item in events}
|
||||
assert "ingress" in kinds
|
||||
assert "station" in kinds
|
||||
dates = [item["date"] for item in events]
|
||||
assert dates == sorted(dates)
|
||||
assert payload["calculation"]["ayanamsa"] == "raman"
|
||||
assert len(events) == 14
|
||||
|
||||
|
||||
def test_ephemeris_events_rejects_range_over_730_days(api_env) -> None:
|
||||
handler = _PostCaptureHandler(
|
||||
"/api/ephemeris_events",
|
||||
{"start_date": "2026-01-01", "end_date": "2028-01-02", "ayanamsa": "raman"},
|
||||
)
|
||||
handler.do_POST()
|
||||
assert handler.status_code == 400
|
||||
payload = handler.payload()
|
||||
assert payload["success"] is False
|
||||
assert "730" in payload["error"]
|
||||
|
||||
|
||||
def test_ephemeris_ayanamsa_changes_the_event_list(api_env) -> None:
|
||||
raman = _PostCaptureHandler(
|
||||
"/api/ephemeris_events",
|
||||
{"start_date": "2026-09-15", "end_date": "2026-12-14", "ayanamsa": "raman"},
|
||||
)
|
||||
lahiri = _PostCaptureHandler(
|
||||
"/api/ephemeris_events",
|
||||
{"start_date": "2026-09-15", "end_date": "2026-12-14", "ayanamsa": "lahiri"},
|
||||
)
|
||||
raman.do_POST()
|
||||
lahiri.do_POST()
|
||||
assert raman.status_code == 200
|
||||
assert lahiri.status_code == 200
|
||||
assert raman.payload()["events"] != lahiri.payload()["events"]
|
||||
assert raman.payload()["calculation"]["ayanamsa"] == "raman"
|
||||
assert lahiri.payload()["calculation"]["ayanamsa"] == "lahiri"
|
||||
Reference in New Issue
Block a user